<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community</title>
    <description>The most recent home feed on DEV Community.</description>
    <link>https://dev.to</link>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed"/>
    <language>en</language>
    <item>
      <title>PDF Tamper Detection API for C#: ASP.NET Core Integration Guide</title>
      <dc:creator>Iurii Rogulia</dc:creator>
      <pubDate>Mon, 03 Aug 2026 10:00:38 +0000</pubDate>
      <link>https://dev.to/iurii_rogulia/pdf-tamper-detection-api-for-c-aspnet-core-integration-guide-44g1</link>
      <guid>https://dev.to/iurii_rogulia/pdf-tamper-detection-api-for-c-aspnet-core-integration-guide-44g1</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Originally published at &lt;a href="https://htpbe.tech/blog/pdf-verification-csharp-dotnet-integration-guide" rel="noopener noreferrer"&gt;htpbe.tech&lt;/a&gt;. The version on htpbe.tech stays in sync with the latest detection algorithm — refer to it for the canonical text.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;PDF fraud is a backend problem, and in enterprise fintech a great deal of that backend runs on .NET. An ASP.NET Core service ingests an uploaded bank statement, a payslip, or a claim packet, writes a row, and hands the document to underwriting — and by the time your controller has returned &lt;code&gt;201 Created&lt;/code&gt;, the document’s claims have already propagated into your business logic. Your KYC provider confirmed the applicant is a real person with a valid identity. It said nothing about whether the PDF they uploaded was edited after the bank generated it. That structural-tampering layer is invisible to identity verification, and the right place to catch it is at ingress: before your service trusts the file, not after.&lt;/p&gt;

&lt;p&gt;This guide walks through integrating the &lt;a href="https://htpbe.tech/api" rel="noopener noreferrer"&gt;PDF tamper detection API&lt;/a&gt; into an ASP.NET Core application — from the first curl command to an idiomatic typed &lt;code&gt;HtpbeClient&lt;/code&gt; built on &lt;code&gt;IHttpClientFactory&lt;/code&gt;, with &lt;code&gt;System.Text.Json&lt;/code&gt; record DTOs, configuration-bound options for the API key, polling with backoff, error handling that distinguishes a configuration failure from a transient one, and a small bank-statement gate that decides accept / reject / review. The patterns target .NET 8 (the current LTS) and use minimal APIs, but everything maps cleanly to a controller-based project. Treat the code as a reference architecture — it runs the real request flow against the documented error codes, but you should adapt and harden it for your own traffic profile and threat model. (If you want the conceptual overview first, start with &lt;a href="https://htpbe.tech/blog/detect-pdf-tampering-programmatically" rel="noopener noreferrer"&gt;How to Detect PDF Tampering Programmatically&lt;/a&gt;. Integrating from another stack? See the &lt;a href="https://htpbe.tech/blog/pdf-verification-java-spring-boot-integration-guide" rel="noopener noreferrer"&gt;Java / Spring Boot&lt;/a&gt;, &lt;a href="https://htpbe.tech/blog/pdf-verification-go-integration-guide" rel="noopener noreferrer"&gt;Go&lt;/a&gt;, &lt;a href="https://htpbe.tech/blog/pdf-verification-nodejs-integration-guide" rel="noopener noreferrer"&gt;Node.js&lt;/a&gt;, &lt;a href="https://htpbe.tech/blog/pdf-verification-api-python-integration-guide" rel="noopener noreferrer"&gt;Python&lt;/a&gt;, and &lt;a href="https://htpbe.tech/blog/pdf-verification-laravel-php-integration-guide" rel="noopener noreferrer"&gt;Laravel / PHP&lt;/a&gt; guides.)&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Two API calls, three verdicts: &lt;code&gt;POST /analyze&lt;/code&gt; returns a top-level &lt;code&gt;id&lt;/code&gt;, then &lt;code&gt;GET /result/{id}&lt;/code&gt; returns the flat verdict object whose &lt;code&gt;status&lt;/code&gt; is one of &lt;code&gt;intact&lt;/code&gt;, &lt;code&gt;modified&lt;/code&gt;, or &lt;code&gt;inconclusive&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;The minimum integration is a typed &lt;code&gt;HttpClient&lt;/code&gt; and two awaited calls. No dependency beyond the framework and &lt;code&gt;System.Text.Json&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Production shape: an &lt;code&gt;HtpbeClient&lt;/code&gt; typed client registered with &lt;code&gt;IHttpClientFactory&lt;/code&gt;, record DTOs with &lt;code&gt;snake_case&lt;/code&gt; naming, a custom exception carrying the status code, and a Polly retry policy that backs off on 5xx and 429 only.&lt;/li&gt;
&lt;li&gt;A &lt;code&gt;DocumentGate&lt;/code&gt; that maps the three verdicts to an &lt;code&gt;Accept&lt;/code&gt; / &lt;code&gt;Reject&lt;/code&gt; / &lt;code&gt;Review&lt;/code&gt; decision for documents that claim institutional origin.&lt;/li&gt;
&lt;li&gt;This is structural PDF tamper and forgery detection — not KYC, not OCR, not AI-text detection. It complements an identity stack; it does not replace one.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Prerequisites
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;.NET 8 SDK (records, &lt;code&gt;required&lt;/code&gt; members, &lt;code&gt;IHttpClientFactory&lt;/code&gt;, minimal APIs)&lt;/li&gt;
&lt;li&gt;An HTPBE API key (Dashboard → copy key)&lt;/li&gt;
&lt;li&gt;Optionally &lt;code&gt;Microsoft.Extensions.Http.Polly&lt;/code&gt; for the resilience policy in Step 6 (the framework &lt;code&gt;HttpClient&lt;/code&gt; works without it)&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Step 1: Test the API with curl
&lt;/h2&gt;

&lt;p&gt;Before writing any C#, confirm your key works. The API uses a two-step flow: &lt;code&gt;POST /analyze&lt;/code&gt; submits a PDF URL and returns a check id, then &lt;code&gt;GET /result/{id}&lt;/code&gt; retrieves the full verdict. (For a language-agnostic overview of what the API detects, see &lt;a href="https://htpbe.tech/how" rel="noopener noreferrer"&gt;how PDF tamper detection works&lt;/a&gt;.)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 1a — submit for analysis:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST https://api.htpbe.tech/v1/analyze &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer YOUR_API_KEY"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"url": "https://api.htpbe.tech/v1/test/clean.pdf"}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You will receive a flat object with a single field: &lt;code&gt;{"id": "00000000-0000-4000-8000-000000000001"}&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 1b — retrieve the result:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl https://api.htpbe.tech/v1/result/YOUR_CHECK_ID &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer YOUR_API_KEY"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You will receive a flat JSON object with &lt;code&gt;"status": "intact"&lt;/code&gt; and the full set of analysis fields:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"00000000-0000-4000-8000-000000000001"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"filename"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"clean.pdf"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"intact"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"origin"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"institutional"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"software"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"creator"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Adobe Acrobat Pro DC"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"producer"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Adobe PDF Library 15.0"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"modification_confidence"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"none"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"has_incremental_updates"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"update_chain_length"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"signature_removed"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"modifications_after_signature"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"modification_markers"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same shape comes back for &lt;code&gt;modified&lt;/code&gt; and &lt;code&gt;inconclusive&lt;/code&gt; verdicts — only the values change. Two fields are conditional: &lt;code&gt;status_reason&lt;/code&gt; appears only when &lt;code&gt;status&lt;/code&gt; is &lt;code&gt;inconclusive&lt;/code&gt;, and &lt;code&gt;outdated_warning&lt;/code&gt; only when the check ran against an older algorithm version. Note that there is no &lt;code&gt;data&lt;/code&gt; wrapper and no numeric risk score — the response is the flat object above, and the whole signal is the verdict plus the named markers.&lt;/p&gt;

&lt;p&gt;The URL &lt;code&gt;https://api.htpbe.tech/v1/test/clean.pdf&lt;/code&gt; is a test mock — it returns a predictable response without consuming quota. Test keys (prefix &lt;code&gt;htpbe_test_&lt;/code&gt;) accept only these mock URLs; live keys (prefix &lt;code&gt;htpbe_live_&lt;/code&gt;) accept any public PDF URL.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: Bind Configuration, Not Constants
&lt;/h2&gt;

&lt;p&gt;Keep the key and base URL out of code. Bind them to a typed options record so they are validated at startup and overridable per environment. The key resolves from configuration — &lt;code&gt;appsettings.json&lt;/code&gt;, an environment variable, or a secrets store — never a hard-coded literal.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;namespace&lt;/span&gt; &lt;span class="nn"&gt;Htpbe&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;sealed&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;HtpbeOptions&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;SectionName&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"Htpbe"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="n"&gt;required&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;ApiKey&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;init&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;BaseUrl&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;init&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"https://api.htpbe.tech/v1/"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;TimeoutSeconds&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;init&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="m"&gt;35&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;MaxResultPollAttempts&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;init&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="m"&gt;5&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Provide the values in &lt;code&gt;appsettings.json&lt;/code&gt;, leaving the key itself empty so it is supplied by an environment variable or user-secret at runtime:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Htpbe"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"ApiKey"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"BaseUrl"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://api.htpbe.tech/v1/"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"TimeoutSeconds"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;35&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"MaxResultPollAttempts"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In development, store the key with &lt;code&gt;dotnet user-secrets set "Htpbe:ApiKey" "htpbe_live_..."&lt;/code&gt;. In production, set the environment variable &lt;code&gt;Htpbe__ApiKey&lt;/code&gt; (the double underscore maps to the nested section). Either way the key never lands in source control. Bind and validate the options in &lt;code&gt;Program.cs&lt;/code&gt; so a missing key fails the application at boot, not on the first document that arrives at 2 a.m.:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Services&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AddOptions&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;HtpbeOptions&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;()&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Bind&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Configuration&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetSection&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;HtpbeOptions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SectionName&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Validate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;o&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;!&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;IsNullOrWhiteSpace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ApiKey&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="s"&gt;"Htpbe:ApiKey is not configured. Set the Htpbe__ApiKey environment variable."&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ValidateOnStart&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 3: The Result DTO
&lt;/h2&gt;

&lt;p&gt;Model the &lt;code&gt;GET /result/{id}&lt;/code&gt; response as C# records. &lt;code&gt;System.Text.Json&lt;/code&gt; maps &lt;code&gt;snake_case&lt;/code&gt; JSON to &lt;code&gt;PascalCase&lt;/code&gt; members when you set &lt;code&gt;JsonNamingPolicy.SnakeCaseLower&lt;/code&gt; on the serializer options (available since .NET 8). Reference types stay nullable so “absent” remains distinguishable from a genuine zero, and unknown fields are ignored by default so a newly added API field never breaks deserialization.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;System.Text.Json.Serialization&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;namespace&lt;/span&gt; &lt;span class="nn"&gt;Htpbe&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;sealed&lt;/span&gt; &lt;span class="k"&gt;record&lt;/span&gt; &lt;span class="nc"&gt;AnalysisResult&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="n"&gt;required&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;Id&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;init&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;Filename&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;init&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;long&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;FileSize&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;init&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;PageCount&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;init&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;AlgorithmVersion&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;init&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;CurrentAlgorithmVersion&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;init&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;OutdatedWarning&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;init&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="c1"&gt;// present only on an outdated check&lt;/span&gt;

    &lt;span class="c1"&gt;// Primary verdict: "intact" | "modified" | "inconclusive"&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="n"&gt;required&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;Status&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;init&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="c1"&gt;// Present only when Status == "inconclusive":&lt;/span&gt;
    &lt;span class="c1"&gt;// "consumer_software_origin" | "online_editor_origin" |&lt;/span&gt;
    &lt;span class="c1"&gt;// "scanned_document" | "unverifiable_metadata"&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;StatusReason&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;init&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="n"&gt;Origin&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;Origin&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;init&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="c1"&gt;// "certain" | "high" | "none" | null&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;ModificationConfidence&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;init&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;Creator&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;init&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;Producer&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;init&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;long&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;CreationDate&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;init&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;     &lt;span class="c1"&gt;// Unix seconds&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;long&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;ModificationDate&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;init&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="c1"&gt;// Unix seconds&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;PdfVersion&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;init&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="n"&gt;DateSequenceValid&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;init&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;MetadataCompletenessScore&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;init&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;XrefCount&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;init&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="n"&gt;HasIncrementalUpdates&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;init&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;UpdateChainLength&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;init&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="n"&gt;HasDigitalSignature&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;init&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;SignatureCount&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;init&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="n"&gt;SignatureRemoved&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;init&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="n"&gt;ModificationsAfterSignature&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;init&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;ObjectCount&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;init&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="n"&gt;HasJavascript&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;init&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="n"&gt;HasEmbeddedFiles&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;init&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="c1"&gt;// Stable HTPBE_* marker ids, e.g. ["HTPBE_SIGNATURE_REMOVED"].&lt;/span&gt;
    &lt;span class="c1"&gt;// Empty when Status is "intact" or "inconclusive".&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="n"&gt;IReadOnlyList&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;ModificationMarkers&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;init&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;

    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;JsonIgnore&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="n"&gt;IsIntact&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;Status&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="s"&gt;"intact"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;JsonIgnore&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="n"&gt;IsModified&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;Status&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="s"&gt;"modified"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;JsonIgnore&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="n"&gt;IsInconclusive&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;Status&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="s"&gt;"inconclusive"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;sealed&lt;/span&gt; &lt;span class="k"&gt;record&lt;/span&gt; &lt;span class="nc"&gt;Origin&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// "consumer_software" | "institutional" | "unknown" |&lt;/span&gt;
    &lt;span class="c1"&gt;// "online_editor" | "scanned"&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;Type&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;init&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;Software&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;init&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// The /analyze response carries only the check id.&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;sealed&lt;/span&gt; &lt;span class="k"&gt;record&lt;/span&gt; &lt;span class="nc"&gt;AnalyzeResponse&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="n"&gt;required&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;Id&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;init&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two fields deserve a closer look. &lt;code&gt;StatusReason&lt;/code&gt; is populated only when &lt;code&gt;Status&lt;/code&gt; is &lt;code&gt;inconclusive&lt;/code&gt;, and it carries one of several values — &lt;code&gt;consumer_software_origin&lt;/code&gt;, &lt;code&gt;online_editor_origin&lt;/code&gt;, &lt;code&gt;scanned_document&lt;/code&gt;, and a few more. The difference matters: a scanned document is benign for a user-submitted handwritten form, but a &lt;code&gt;consumer_software_origin&lt;/code&gt; on something that claims to be a payslip is the kind of origin you would not expect from a real payroll system — that class covers both consumer apps and freely available HTML-to-PDF renderers, so it is a strong signal to route for review. Branch on the specific reason, not just on the top-level &lt;code&gt;inconclusive&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;ModificationMarkers&lt;/code&gt; returns stable, machine-readable ids prefixed &lt;code&gt;HTPBE_&lt;/code&gt; — for example &lt;code&gt;HTPBE_SIGNATURE_REMOVED&lt;/code&gt;, &lt;code&gt;HTPBE_DATES_DISAGREE&lt;/code&gt;, &lt;code&gt;HTPBE_POST_SIGNATURE_EDIT&lt;/code&gt;, &lt;code&gt;HTPBE_MULTIPLE_REVISION_LAYERS&lt;/code&gt;. Branch your integration logic on the id; render the human-readable label from the dictionary published on &lt;a href="https://htpbe.tech/how" rel="noopener noreferrer"&gt;htpbe.tech/how&lt;/a&gt;. These ids are part of the public contract and never change once shipped. The API does not return a numeric risk score — the verdict plus the named markers are the whole signal, by design, so there is no threshold to tune on your side.&lt;/p&gt;

&lt;p&gt;A small enum keeps the rest of your codebase from comparing against bare string literals:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;namespace&lt;/span&gt; &lt;span class="nn"&gt;Htpbe&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;enum&lt;/span&gt; &lt;span class="n"&gt;Verdict&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;Intact&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Modified&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Inconclusive&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;VerdictParser&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="n"&gt;Verdict&lt;/span&gt; &lt;span class="nf"&gt;Parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="k"&gt;switch&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="s"&gt;"intact"&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;Verdict&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Intact&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s"&gt;"modified"&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;Verdict&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Modified&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s"&gt;"inconclusive"&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;Verdict&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Inconclusive&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;ArgumentOutOfRangeException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="k"&gt;nameof&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"unknown status from HTPBE"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 4: A Typed Exception
&lt;/h2&gt;

&lt;p&gt;A 401 means your key is wrong; a 402 means the credit pool is dry; a 500 is transient. Both the retry layer and your business logic need to branch on the status code, so wrap every non-success response in a typed exception that carries it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;namespace&lt;/span&gt; &lt;span class="nn"&gt;Htpbe&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;sealed&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;HtpbeApiException&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Exception&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;StatusCode&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;Code&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;                 &lt;span class="c1"&gt;// machine-readable code from the JSON body&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;RetryAfterSeconds&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;      &lt;span class="c1"&gt;// parsed from Retry-After on 429; null if absent&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="nf"&gt;HtpbeApiException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;statusCode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;code&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;retryAfterSeconds&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;base&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;$"htpbe: &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;statusCode&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;code&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s"&gt;: &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;StatusCode&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;statusCode&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="n"&gt;Code&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;code&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="n"&gt;RetryAfterSeconds&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;retryAfterSeconds&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="c1"&gt;// Only 5xx and 429 are transient. Every other 4xx is permanent —&lt;/span&gt;
    &lt;span class="c1"&gt;// retrying it burns latency and, for 402, can never succeed until&lt;/span&gt;
    &lt;span class="c1"&gt;// the account is topped up.&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="n"&gt;Retryable&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;StatusCode&lt;/span&gt; &lt;span class="p"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="m"&gt;500&lt;/span&gt; &lt;span class="p"&gt;||&lt;/span&gt; &lt;span class="n"&gt;StatusCode&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="m"&gt;429&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 5: The Typed HttpClient
&lt;/h2&gt;

&lt;p&gt;Here is the complete client. It is a typed &lt;code&gt;HttpClient&lt;/code&gt; — registered with &lt;code&gt;IHttpClientFactory&lt;/code&gt; in Step 6 — so the factory owns the connection pool and the &lt;code&gt;Authorization&lt;/code&gt; header is set once at registration. The client exposes one public method, &lt;code&gt;VerifyAsync&lt;/code&gt;, that runs both steps of the flow; a private &lt;code&gt;ParseErrorAsync&lt;/code&gt; converts every non-success response into an &lt;code&gt;HtpbeApiException&lt;/code&gt;, reading the JSON error body and the &lt;code&gt;Retry-After&lt;/code&gt; header in one place.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;System.Net&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;System.Net.Http.Json&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;System.Text.Json&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;Microsoft.Extensions.Options&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;namespace&lt;/span&gt; &lt;span class="nn"&gt;Htpbe&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;sealed&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;HtpbeClient&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="n"&gt;HttpClient&lt;/span&gt; &lt;span class="n"&gt;_http&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;_maxPollAttempts&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="n"&gt;JsonSerializerOptions&lt;/span&gt; &lt;span class="n"&gt;JsonOptions&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;PropertyNamingPolicy&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;JsonNamingPolicy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SnakeCaseLower&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;PropertyNameCaseInsensitive&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;};&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="nf"&gt;HtpbeClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;HttpClient&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;IOptions&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;HtpbeOptions&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;_http&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="n"&gt;_maxPollAttempts&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;MaxResultPollAttempts&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="c1"&gt;/// &amp;lt;summary&amp;gt;&lt;/span&gt;
    &lt;span class="c1"&gt;/// Submits a PDF URL and returns the full verdict. The two steps are kept&lt;/span&gt;
    &lt;span class="c1"&gt;/// separate on purpose: POST /analyze is the billable, job-creating call,&lt;/span&gt;
    &lt;span class="c1"&gt;/// GET /result/{id} is a free read. The resilience policy (Step 6) wraps&lt;/span&gt;
    &lt;span class="c1"&gt;/// the whole client, but only transient failures are retried — a permanent&lt;/span&gt;
    &lt;span class="c1"&gt;/// 402 short-circuits immediately.&lt;/span&gt;
    &lt;span class="c1"&gt;/// &amp;lt;/summary&amp;gt;&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;AnalysisResult&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;VerifyAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;pdfUrl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;originalFilename&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;CancellationToken&lt;/span&gt; &lt;span class="n"&gt;ct&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;SubmitAnalysisAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pdfUrl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;originalFilename&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ct&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;GetResultAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ct&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;SubmitAnalysisAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;pdfUrl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;originalFilename&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;CancellationToken&lt;/span&gt; &lt;span class="n"&gt;ct&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// The API accepts a JSON body with the PDF URL; original_filename is optional.&lt;/span&gt;
        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;originalFilename&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;
            &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="n"&gt;Dictionary&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"url"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pdfUrl&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
            &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="n"&gt;Dictionary&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"url"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pdfUrl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"original_filename"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;originalFilename&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;

        &lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;var&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;_http&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;PostAsJsonAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"analyze"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;JsonOptions&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ct&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(!&lt;/span&gt;&lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;IsSuccessStatusCode&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;ParseErrorAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ct&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Content&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ReadFromJsonAsync&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;AnalyzeResponse&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;&lt;span class="n"&gt;JsonOptions&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ct&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt; &lt;span class="p"&gt;||&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;IsNullOrEmpty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Id&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
            &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;HtpbeApiException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;502&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"BAD_RESPONSE"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"analyze response missing id"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Id&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;AnalysisResult&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;GetResultAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;CancellationToken&lt;/span&gt; &lt;span class="n"&gt;ct&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// POST /analyze runs the analysis synchronously, so the result is normally&lt;/span&gt;
        &lt;span class="c1"&gt;// ready on the first GET. The bounded poll below is defensive: it tolerates&lt;/span&gt;
        &lt;span class="c1"&gt;// a brief replication lag and re-reads on a transient 404 before giving up.&lt;/span&gt;
        &lt;span class="n"&gt;HtpbeApiException&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;lastError&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;attempt&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;attempt&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;_maxPollAttempts&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;attempt&lt;/span&gt;&lt;span class="p"&gt;++)&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;var&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;_http&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;$"result/&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ct&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;IsSuccessStatusCode&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Content&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ReadFromJsonAsync&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;AnalysisResult&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;&lt;span class="n"&gt;JsonOptions&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ct&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
                &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="p"&gt;??&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;HtpbeApiException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;502&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"BAD_RESPONSE"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"empty result body"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;

            &lt;span class="n"&gt;lastError&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;ParseErrorAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ct&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

            &lt;span class="c1"&gt;// Only a 404 is worth re-reading (the row may not be visible yet).&lt;/span&gt;
            &lt;span class="c1"&gt;// Every other error is terminal — surface it without burning attempts.&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;StatusCode&lt;/span&gt; &lt;span class="p"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;HttpStatusCode&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NotFound&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="n"&gt;lastError&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

            &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Delay&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;TimeSpan&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;FromMilliseconds&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;300&lt;/span&gt; &lt;span class="p"&gt;*&lt;/span&gt; &lt;span class="n"&gt;attempt&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;ct&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="n"&gt;lastError&lt;/span&gt; &lt;span class="p"&gt;??&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;HtpbeApiException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;504&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"RESULT_TIMEOUT"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"result not ready after polling"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;HtpbeApiException&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;ParseErrorAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;HttpResponseMessage&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;CancellationToken&lt;/span&gt; &lt;span class="n"&gt;ct&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;statusCode&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;StatusCode&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;code&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"UNKNOWN"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ReasonPhrase&lt;/span&gt; &lt;span class="p"&gt;??&lt;/span&gt; &lt;span class="n"&gt;statusCode&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ToString&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

        &lt;span class="k"&gt;try&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;error&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Content&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ReadFromJsonAsync&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;ErrorBody&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;&lt;span class="n"&gt;JsonOptions&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ct&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;error&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="n"&gt;code&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;error&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Code&lt;/span&gt; &lt;span class="p"&gt;??&lt;/span&gt; &lt;span class="n"&gt;code&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
                &lt;span class="n"&gt;message&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;error&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Error&lt;/span&gt; &lt;span class="p"&gt;??&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;JsonException&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="c1"&gt;// body was not JSON — keep the status-derived defaults&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="n"&gt;message&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;statusCode&lt;/span&gt; &lt;span class="k"&gt;switch&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="m"&gt;401&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s"&gt;"invalid API key — check Htpbe:ApiKey"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="m"&gt;402&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s"&gt;"no credits available for this key — top up or subscribe"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="m"&gt;403&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s"&gt;"test key sent to a live URL, or vice versa"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="m"&gt;413&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s"&gt;"PDF exceeds the 10 MB size limit"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="m"&gt;422&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s"&gt;"the URL did not return a valid PDF file"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;};&lt;/span&gt;

        &lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;retryAfter&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;statusCode&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="m"&gt;429&lt;/span&gt; &lt;span class="p"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;RetryAfter&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="n"&gt;ra&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;retryAfter&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;ParseRetryAfter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ra&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;HtpbeApiException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;statusCode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;code&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;retryAfter&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="c1"&gt;// Handles both the delta-seconds form and the HTTP-date form,&lt;/span&gt;
    &lt;span class="c1"&gt;// clamped to [1, 600]. Returns null when neither is present.&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nf"&gt;ParseRetryAfter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;System&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Net&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Http&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;RetryConditionHeaderValue&lt;/span&gt; &lt;span class="n"&gt;ra&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ra&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Delta&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="n"&gt;delta&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Clamp&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="n"&gt;delta&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TotalSeconds&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;600&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ra&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Date&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="n"&gt;date&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Clamp&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)(&lt;/span&gt;&lt;span class="n"&gt;date&lt;/span&gt; &lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="n"&gt;DateTimeOffset&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;UtcNow&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="n"&gt;TotalSeconds&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;600&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;sealed&lt;/span&gt; &lt;span class="k"&gt;record&lt;/span&gt; &lt;span class="nc"&gt;ErrorBody&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;Error&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;init&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;Code&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;init&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two status codes deserve explicit handling in your own code:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;402&lt;/code&gt; (Payment Required)&lt;/strong&gt; — the key has no credit source left. Credits are universal: a subscription’s monthly quota, a one-time top-up batch, and the welcome credits all draw from one pool. A 402 means all three are exhausted (or there is no active plan on a live key). &lt;code&gt;HtpbeApiException.Retryable&lt;/code&gt; returns &lt;code&gt;false&lt;/code&gt; for it — surface it to your billing path rather than retrying, because retrying fails identically until the account is topped up at &lt;a href="https://htpbe.tech/pricing" rel="noopener noreferrer"&gt;the pricing page&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;429&lt;/code&gt; (Too Many Requests)&lt;/strong&gt; — this is server-wide concurrency, not per-key rate limiting. The response carries a &lt;code&gt;Retry-After&lt;/code&gt; header, which &lt;code&gt;ParseRetryAfter&lt;/code&gt; reads (both delta-seconds and HTTP-date forms, clamped to &lt;code&gt;[1, 600]&lt;/code&gt;) and stashes on the exception. Your retry policy reads that value before falling back to exponential backoff.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Step 6: Register the Client and Retry on Transient Failures Only
&lt;/h2&gt;

&lt;p&gt;Register &lt;code&gt;HtpbeClient&lt;/code&gt; as a typed client. &lt;code&gt;IHttpClientFactory&lt;/code&gt; manages the underlying handler pool, sets the base address and default headers once, and lets you attach a resilience policy. The policy retries &lt;strong&gt;only&lt;/strong&gt; on the transient codes — 5xx and 429 — and never on the permanent 4xx codes like 401, 402, or 422, where retrying would burn latency and credits without ever succeeding.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;System.Net&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;Microsoft.Extensions.Options&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;Polly&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;Polly.Extensions.Http&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// In Program.cs&lt;/span&gt;

&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Services&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AddHttpClient&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;HtpbeClient&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;((&lt;/span&gt;&lt;span class="n"&gt;sp&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;opts&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;sp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;GetRequiredService&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;IOptions&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;HtpbeOptions&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&amp;gt;().&lt;/span&gt;&lt;span class="n"&gt;Value&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;BaseAddress&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;Uri&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;opts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;BaseUrl&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DefaultRequestHeaders&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Authorization&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt;
            &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="n"&gt;System&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Net&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Http&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AuthenticationHeaderValue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Bearer"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;opts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ApiKey&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DefaultRequestHeaders&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Accept&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="n"&gt;System&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Net&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Http&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;MediaTypeWithQualityHeaderValue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"application/json"&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
        &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Timeout&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;TimeSpan&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;FromSeconds&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;opts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TimeoutSeconds&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddPolicyHandler&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;TransientRetryPolicy&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;

&lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="n"&gt;IAsyncPolicy&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;HttpResponseMessage&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;TransientRetryPolicy&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt;
    &lt;span class="n"&gt;HttpPolicyExtensions&lt;/span&gt;
        &lt;span class="c1"&gt;// Network failures and 5xx are handled by HandleTransientHttpError;&lt;/span&gt;
        &lt;span class="c1"&gt;// add 429 explicitly so a capacity signal also backs off and retries.&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;HandleTransientHttpError&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;OrResult&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;StatusCode&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="n"&gt;HttpStatusCode&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TooManyRequests&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WaitAndRetryAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;retryCount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;sleepDurationProvider&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;attempt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt;
            &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="c1"&gt;// Honour a server-supplied Retry-After when present (429 capacity),&lt;/span&gt;
                &lt;span class="c1"&gt;// otherwise fall back to exponential backoff: 1s, 2s, 4s.&lt;/span&gt;
                &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;retryAfter&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Result&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="n"&gt;Headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;RetryAfter&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="n"&gt;Delta&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
                &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;retryAfter&lt;/span&gt; &lt;span class="p"&gt;??&lt;/span&gt; &lt;span class="n"&gt;TimeSpan&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;FromSeconds&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Pow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;attempt&lt;/span&gt; &lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
            &lt;span class="p"&gt;},&lt;/span&gt;
            &lt;span class="n"&gt;onRetryAsync&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CompletedTask&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you would rather not take a Polly dependency, the bounded poll inside &lt;code&gt;GetResultAsync&lt;/code&gt; already handles the most common transient case (a result that is not yet visible), and you can wrap &lt;code&gt;VerifyAsync&lt;/code&gt; in a small &lt;code&gt;for&lt;/code&gt; loop that re-throws when &lt;code&gt;HtpbeApiException.Retryable&lt;/code&gt; is &lt;code&gt;false&lt;/code&gt;. For most integrations the Polly handler is the cleanest fit, because it sits at the &lt;code&gt;HttpClient&lt;/code&gt; layer and applies to both the submit and the read uniformly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 7: The Bank-Statement Gate
&lt;/h2&gt;

&lt;p&gt;The client returns facts. Turning those facts into an &lt;code&gt;Accept&lt;/code&gt; / &lt;code&gt;Reject&lt;/code&gt; / &lt;code&gt;Review&lt;/code&gt; decision is a policy choice that depends on what the document claims to be. A bank statement, a payslip, or a diploma claims institutional origin, so anything other than &lt;code&gt;intact&lt;/code&gt; should stop the automated path. A user-generated form is held to a looser standard.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;namespace&lt;/span&gt; &lt;span class="nn"&gt;Htpbe&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;enum&lt;/span&gt; &lt;span class="n"&gt;Decision&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;Accept&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Reject&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Review&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;DocumentGate&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;/// &amp;lt;summary&amp;gt;&lt;/span&gt;
    &lt;span class="c1"&gt;/// Maps a verdict to a decision for documents that claim institutional&lt;/span&gt;
    &lt;span class="c1"&gt;/// origin (bank statements, payslips, diplomas). For these, "inconclusive"&lt;/span&gt;
    &lt;span class="c1"&gt;/// is treated as strictly as "modified": a document that should have come&lt;/span&gt;
    &lt;span class="c1"&gt;/// from a bank's own system but looks like it was built in Word does not&lt;/span&gt;
    &lt;span class="c1"&gt;/// get the benefit of the doubt.&lt;/span&gt;
    &lt;span class="c1"&gt;/// &amp;lt;/summary&amp;gt;&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="n"&gt;Decision&lt;/span&gt; &lt;span class="nf"&gt;ForInstitutional&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;AnalysisResult&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt;
        &lt;span class="n"&gt;VerdictParser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Status&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;switch&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;Verdict&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Modified&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;Decision&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Reject&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="c1"&gt;// A bank statement that comes back inconclusive should not be&lt;/span&gt;
            &lt;span class="c1"&gt;// auto-accepted: it typically came from consumer software rather&lt;/span&gt;
            &lt;span class="c1"&gt;// than a bank's own system — a signal to route for review, not&lt;/span&gt;
            &lt;span class="c1"&gt;// proof of tampering. Send it to a human.&lt;/span&gt;
            &lt;span class="n"&gt;Verdict&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Inconclusive&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;Decision&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Review&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;Verdict&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Intact&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;Decision&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Accept&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;Decision&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Review&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Wire the client and the gate into a minimal-API endpoint that accepts a JSON body with a reachable URL. The handler runs the check before any business logic touches the file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="c1"&gt;// In Program.cs, after building the app&lt;/span&gt;

&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;MapPost&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/api/documents/verify"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;VerifyRequest&lt;/span&gt; &lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;HtpbeClient&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;CancellationToken&lt;/span&gt; &lt;span class="n"&gt;ct&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;IsNullOrWhiteSpace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DocumentUrl&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;Results&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;BadRequest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;error&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"documentUrl is required"&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

    &lt;span class="n"&gt;AnalysisResult&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;VerifyAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DocumentUrl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;OriginalFilename&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ct&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;HtpbeApiException&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;MapApiError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;DocumentGate&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ForInstitutional&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;switch&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;Decision&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Reject&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;Results&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;UnprocessableEntity&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;decision&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"reject"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;reason&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"document modified after creation"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;modification_markers&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ModificationMarkers&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;}),&lt;/span&gt;
        &lt;span class="n"&gt;Decision&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Review&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;Results&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Accepted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;decision&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"review"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;status_reason&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;StatusReason&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;}),&lt;/span&gt;
        &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;Results&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Ok&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;decision&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"accept"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;check_id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Id&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
    &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="n"&gt;IResult&lt;/span&gt; &lt;span class="nf"&gt;MapApiError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;HtpbeApiException&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;StatusCode&lt;/span&gt; &lt;span class="k"&gt;switch&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Configuration / billing errors — never leak the cause to the caller.&lt;/span&gt;
    &lt;span class="m"&gt;401&lt;/span&gt; &lt;span class="k"&gt;or&lt;/span&gt; &lt;span class="m"&gt;402&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;Results&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Problem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="s"&gt;"verification temporarily unavailable"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;statusCode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;503&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="m"&gt;422&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;Results&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;UnprocessableEntity&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;error&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"the URL did not return a valid PDF"&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
    &lt;span class="m"&gt;413&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;Results&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Problem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"PDF must be under 10 MB"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;statusCode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;413&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;Results&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Problem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"verification failed"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;statusCode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;502&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;sealed&lt;/span&gt; &lt;span class="k"&gt;record&lt;/span&gt; &lt;span class="nc"&gt;VerifyRequest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;DocumentUrl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;OriginalFilename&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An &lt;code&gt;inconclusive&lt;/code&gt; result should not be auto-accepted — it typically indicates the file came from consumer software, an online editor, an HTML renderer, or a scanner rather than an institutional generator. That is a signal to route for review, not proof of tampering. For a deeper explanation, see &lt;a href="https://htpbe.tech/blog/what-inconclusive-means-pdf-verification-results" rel="noopener noreferrer"&gt;what “inconclusive” really means&lt;/a&gt;. For documents that claim institutional origin, treat &lt;code&gt;inconclusive&lt;/code&gt; with the same caution as &lt;code&gt;modified&lt;/code&gt;: do not accept automatically, route to a human reviewer. Inverting that policy — treating &lt;code&gt;inconclusive&lt;/code&gt; as a pass — is the single most common integration mistake, because it hands an automatic accept to exactly the consumer-software-built documents a bank statement should never be.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 8: Giving the API a Reachable URL
&lt;/h2&gt;

&lt;p&gt;The API does not accept file uploads — it downloads the PDF from a URL you supply, so the file must be publicly reachable for the few seconds the analysis takes. The cleanest pattern is a short-lived presigned URL from your object store: you never expose the bucket, the link expires in minutes, and passing &lt;code&gt;originalFilename&lt;/code&gt; keeps the audit trail readable instead of showing an opaque storage key.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Store the upload privately, mint a 5-minute presigned GET URL, verify.&lt;/span&gt;
&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;$"incoming/&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;Guid&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;NewGuid&lt;/span&gt;&lt;span class="p"&gt;()}&lt;/span&gt;&lt;span class="s"&gt;.pdf"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;s3&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;PutObjectAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="n"&gt;PutObjectRequest&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;BucketName&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;bucket&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;Key&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;InputStream&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pdfStream&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;ContentType&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"application/pdf"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="n"&gt;ct&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;presignedUrl&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;s3&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetPreSignedURLAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="n"&gt;GetPreSignedUrlRequest&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;BucketName&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;bucket&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;Key&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;Expires&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;DateTime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;UtcNow&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddMinutes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;5&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;Verb&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;HttpVerb&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;GET&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;VerifyAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;presignedUrl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;originalFilename&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ct&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same pattern works with Azure Blob Storage (a SAS token via &lt;code&gt;BlobClient.GenerateSasUri&lt;/code&gt;), Google Cloud Storage (&lt;code&gt;UrlSigner&lt;/code&gt;), or Cloudflare R2 (S3-compatible — reuse the AWS SDK with the R2 endpoint). One security note: the API fetches whatever URL you give it, so if a URL ever comes from untrusted input (a user-pasted link, a webhook payload), validate that it resolves to a public host first — reject &lt;code&gt;localhost&lt;/code&gt;, &lt;code&gt;169.254.169.254&lt;/code&gt; (cloud metadata), and the RFC 1918 private ranges to close the SSRF surface. When you mint the URL yourself from a private bucket the risk is minimal, but the validation belongs in the request flow either way.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 9: Testing Without Burning Quota
&lt;/h2&gt;

&lt;p&gt;Every plan includes a test API key (prefix &lt;code&gt;htpbe_test_&lt;/code&gt;) that accepts only mock URLs of the form &lt;code&gt;https://api.htpbe.tech/v1/test/{filename}.pdf&lt;/code&gt; and returns deterministic responses — like Stripe test cards, with no quota cost. Point an integration test at these fixtures to cover every branch of the gate. With &lt;code&gt;WebApplicationFactory&amp;lt;Program&amp;gt;&lt;/code&gt; you exercise the real &lt;code&gt;HtpbeClient&lt;/code&gt;, configured with the test key:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;Microsoft.AspNetCore.Mvc.Testing&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;Microsoft.Extensions.DependencyInjection&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;Xunit&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;sealed&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;HtpbeClientTests&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;IClassFixture&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;WebApplicationFactory&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Program&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="n"&gt;HtpbeClient&lt;/span&gt; &lt;span class="n"&gt;_client&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="nf"&gt;HtpbeClientTests&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;WebApplicationFactory&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Program&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;factory&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;configured&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;factory&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WithWebHostBuilder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;builder&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt;
            &lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;UseSetting&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Htpbe:ApiKey"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="n"&gt;Environment&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetEnvironmentVariable&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"HTPBE_TEST_API_KEY"&lt;/span&gt;&lt;span class="p"&gt;)!));&lt;/span&gt;
        &lt;span class="n"&gt;_client&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;configured&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Services&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;GetRequiredService&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;HtpbeClient&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;();&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Fact&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt; &lt;span class="nf"&gt;CleanDocumentReturnsIntact&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;_client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;VerifyAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"https://api.htpbe.tech/v1/test/clean.pdf"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="n"&gt;Assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Equal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"intact"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Status&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="n"&gt;Assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Empty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ModificationMarkers&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="n"&gt;Assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Equal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Decision&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Accept&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;DocumentGate&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ForInstitutional&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Fact&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt; &lt;span class="nf"&gt;SignatureRemovedIsRejected&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;_client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;VerifyAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"https://api.htpbe.tech/v1/test/signature-removed.pdf"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="n"&gt;Assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Equal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"modified"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Status&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="n"&gt;Assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;True&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SignatureRemoved&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="n"&gt;Assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Equal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Decision&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Reject&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;DocumentGate&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ForInstitutional&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Fact&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt; &lt;span class="nf"&gt;InconclusiveIsRoutedToReview&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;_client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;VerifyAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"https://api.htpbe.tech/v1/test/inconclusive.pdf"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="n"&gt;Assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Equal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"inconclusive"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Status&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="n"&gt;Assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;NotNull&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;StatusReason&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="n"&gt;Assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Equal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Decision&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Review&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;DocumentGate&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ForInstitutional&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Useful fixtures: &lt;code&gt;clean.pdf&lt;/code&gt; → &lt;code&gt;intact&lt;/code&gt;, &lt;code&gt;signature-removed.pdf&lt;/code&gt; → &lt;code&gt;modified&lt;/code&gt;, &lt;code&gt;dates-mismatch.pdf&lt;/code&gt; → &lt;code&gt;modified&lt;/code&gt;, and &lt;code&gt;inconclusive.pdf&lt;/code&gt; → &lt;code&gt;inconclusive&lt;/code&gt;. For pure unit tests of the endpoint and gate without any network, inject a fake &lt;code&gt;HttpMessageHandler&lt;/code&gt; into the &lt;code&gt;HtpbeClient&lt;/code&gt; and return canned JSON, or test &lt;code&gt;DocumentGate.ForInstitutional&lt;/code&gt; directly against a hand-built &lt;code&gt;AnalysisResult&lt;/code&gt;. Keep test and live keys in separate configuration sources and never commit either.&lt;/p&gt;

&lt;p&gt;For audit dashboards, &lt;code&gt;GET /api/v1/checks&lt;/code&gt; returns a paginated list of every result for your key — filter by &lt;code&gt;status&lt;/code&gt; and &lt;code&gt;limit&lt;/code&gt; (&lt;code&gt;/checks?status=modified&amp;amp;limit=50&lt;/code&gt;, same &lt;code&gt;Authorization&lt;/code&gt; header). When you reach your monthly quota, further requests return &lt;code&gt;402 PAYMENT_REQUIRED&lt;/code&gt; until it resets — add a one-time credit pack or move to a higher tier to keep going, and handle the 402 so a quota boundary never silently drops a check.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the Verdicts Mean
&lt;/h2&gt;

&lt;p&gt;The whole signal is three verdicts and a list of named markers. Encoding them correctly in your &lt;code&gt;DocumentGate&lt;/code&gt; matters more than any other choice in the integration:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;intact&lt;/code&gt;&lt;/strong&gt; — no post-creation modification was detected and the origin looks institutional. Safe to accept on the automated path.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;modified&lt;/code&gt;&lt;/strong&gt; — forensic evidence of an edit after the document was created. The &lt;code&gt;modification_markers&lt;/code&gt; array names the signal: &lt;code&gt;HTPBE_DATES_DISAGREE&lt;/code&gt; for inconsistent internal timestamps, &lt;code&gt;HTPBE_SIGNATURE_REMOVED&lt;/code&gt; for a stripped digital signature, &lt;code&gt;HTPBE_POST_SIGNATURE_EDIT&lt;/code&gt; for changes made after signing, &lt;code&gt;HTPBE_MULTIPLE_REVISION_LAYERS&lt;/code&gt; for a document saved repeatedly after creation. Reject, or route to fraud review.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;inconclusive&lt;/code&gt;&lt;/strong&gt; — the document was built with consumer software, an online editor, an HTML renderer, or a scanner, so there is no institutional “original” to verify integrity against. This is &lt;strong&gt;not&lt;/strong&gt; a failure and &lt;strong&gt;not&lt;/strong&gt; a clean pass — it is a routing signal. For a document that should have come from an institution (a bank statement, a payslip), &lt;code&gt;inconclusive&lt;/code&gt; means it did not, which is exactly why &lt;code&gt;DocumentGate.ForInstitutional&lt;/code&gt; routes it to a human reviewer.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What This Does Not Catch
&lt;/h2&gt;

&lt;p&gt;Structural analysis has honest limits, and an ASP.NET Core service making automated decisions should encode them rather than overstate the verdict:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Content fabricated in one pass.&lt;/strong&gt; If someone opens Word, types a false salary, and exports once, the file was never modified after creation — it is structurally consistent. The fraud happened at authorship, not at the byte level. This is exactly why a payslip from a consumer tool tends to return &lt;code&gt;inconclusive&lt;/code&gt; rather than &lt;code&gt;intact&lt;/code&gt;: the analysis cannot vouch for a document anyone could have produced from scratch.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Born-synthetic forgeries.&lt;/strong&gt; A fake document generated programmatically with a valid-looking account number and a real logo — never derived from a genuine original — has no post-creation edit to detect. Catching that is a content-verification problem (does this account number exist, does this employer match payroll records), a different product category from structural tamper detection.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Documents rebuilt from scratch in the original’s software.&lt;/strong&gt; A determined attacker who recreates a document in the same institutional tool and matches the metadata leaves few structural signals. This is rare and high-effort, but possible.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Encrypted or password-protected PDFs.&lt;/strong&gt; The service cannot parse a file it cannot open; remove the password before submitting.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These limits are why structural tamper detection works as one layer in a fraud-detection stack, not the whole stack. Pair the structural verdict with domain checks — amount validation, account-number lookups, sender authentication, and your KYC or OCR provider — for a layered defense. The structural layer answers a question identity verification cannot: was this file edited after it was issued? See &lt;a href="https://htpbe.tech/blog/pdf-fraud-prevention-best-practices" rel="noopener noreferrer"&gt;PDF Fraud Prevention Best Practices&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Decisions Before You Ship
&lt;/h2&gt;

&lt;p&gt;The integration surface is intentionally small: one POST, one GET, three verdicts, the typed exception above. The complexity lives on the .NET side, and two choices matter most:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Where verification runs.&lt;/strong&gt; Synchronous inside the request handler gives the caller an immediate decision but blocks for a few seconds; a background &lt;code&gt;IHostedService&lt;/code&gt; or a message-driven consumer (Azure Service Bus, RabbitMQ) returns instantly and defers the verdict. Sync suits low-volume B2B onboarding; async suits high-volume portals. Because the call is fully &lt;code&gt;async&lt;/code&gt;/&lt;code&gt;await&lt;/code&gt; and the analysis takes 2–5 seconds, the synchronous path is cheap enough for most onboarding flows.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;inconclusive&lt;/code&gt; routing.&lt;/strong&gt; For documents that claim institutional origin (bank statements, diplomas, payslips), treat &lt;code&gt;inconclusive&lt;/code&gt; with the same caution as &lt;code&gt;modified&lt;/code&gt; and route to human review — that is what &lt;code&gt;DocumentGate.ForInstitutional&lt;/code&gt; encodes. For genuinely user-generated content it may be acceptable as-is, so you may want a second gate with a looser policy.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;To start, &lt;a href="https://htpbe.tech/auth/signup" rel="noopener noreferrer"&gt;sign up for HTPBE&lt;/a&gt; — new accounts get five checks to try, then pay-per-check credits or a subscription (see &lt;a href="https://htpbe.tech/pricing" rel="noopener noreferrer"&gt;current pricing&lt;/a&gt;) — copy your test key, and run the curl call from Step 1. The &lt;a href="https://htpbe.tech/api" rel="noopener noreferrer"&gt;full API reference&lt;/a&gt; documents every response field, error code, and the marker dictionary the .NET client branches on.&lt;/p&gt;

</description>
      <category>pdf</category>
      <category>tutorial</category>
      <category>api</category>
      <category>fraud</category>
    </item>
    <item>
      <title>Building Coordination Infrastructure: What 32 MCP Servers Without a Bus Look Like</title>
      <dc:creator>Gabriel Mahia</dc:creator>
      <pubDate>Mon, 03 Aug 2026 10:00:00 +0000</pubDate>
      <link>https://dev.to/gabrielmahia/building-coordination-infrastructure-what-32-mcp-servers-without-a-bus-look-like-243c</link>
      <guid>https://dev.to/gabrielmahia/building-coordination-infrastructure-what-32-mcp-servers-without-a-bus-look-like-243c</guid>
      <description>&lt;p&gt;Thirty-two MCP servers for Kenya's coordination domains.&lt;/p&gt;

&lt;p&gt;Water. Health. Agriculture. Insurance. Land. Education. Transport. Tax. Labor. Market prices.&lt;/p&gt;

&lt;p&gt;Each one works. None talks to any other.&lt;/p&gt;

&lt;p&gt;This is what 32 isolated tools looks like in practice: a CHW in Kisumu flags a cholera cluster. The water quality system doesn't know. The county procurement system doesn't know. The emergency medicine supply chain doesn't know. The tools that need to respond are all present. The coordination between them doesn't exist.&lt;/p&gt;

&lt;h2&gt;
  
  
  The architectural problem
&lt;/h2&gt;

&lt;p&gt;The Model Context Protocol ecosystem is growing fast. For most of the world, it's growing in domains that already have coordination infrastructure: calendar apps, email clients, project management tools. These tools coordinate with each other through years of API integrations, webhooks, Zapier connections, and shared data stores.&lt;/p&gt;

&lt;p&gt;African coordination domains don't have that legacy. They're being built now, from scratch, on MCP.&lt;/p&gt;

&lt;p&gt;That means the coordination layer has to be built at the same time.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a coordination layer looks like at the MCP level
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/gabrielmahia/africa-coord-bus" rel="noopener noreferrer"&gt;&lt;code&gt;africa-coord-bus&lt;/code&gt;&lt;/a&gt; defines a &lt;code&gt;CoordinationEvent&lt;/code&gt; — a standard cross-domain signal schema — and an &lt;code&gt;EventBus&lt;/code&gt; that routes events to the tools that need to respond.&lt;/p&gt;

&lt;p&gt;The routing table is explicit:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Trigger&lt;/th&gt;
&lt;th&gt;Cascade&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;water.drought_alert&lt;/code&gt; (Warning+)&lt;/td&gt;
&lt;td&gt;bima-mcp, kilimo-mcp, soko-mcp&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;water.drought_alert&lt;/code&gt; (Alert+)&lt;/td&gt;
&lt;td&gt;+ afya-mcp, county-mcp&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;health.disease_outbreak&lt;/code&gt; (cholera)&lt;/td&gt;
&lt;td&gt;wapimaji-mcp water quality check&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;agriculture.price_spike&lt;/code&gt; (&amp;gt;30%)&lt;/td&gt;
&lt;td&gt;afya-mcp food security watch&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;water.flood_alert&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;afya-mcp waterborne watch, county-mcp&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Custom rules take three lines:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;africa_coord_bus&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;RoutingRule&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;EventDomain&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;EventSeverity&lt;/span&gt;

&lt;span class="n"&gt;bus&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;routing&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;RoutingRule&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;locust→emergency_procurement&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;trigger_domain&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;EventDomain&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AGRICULTURE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;trigger_event_type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;locust_swarm&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;trigger_min_severity&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;EventSeverity&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ALERT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;target_actions&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;fomu-mcp.emergency_procurement&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;county-mcp.agriculture_alert&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The composable coordination stack
&lt;/h2&gt;

&lt;p&gt;The pattern that's emerging for East Africa:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Public domain datasets (HuggingFace)
        │ grounding knowledge
        ▼
32 MCP servers (PyPI) ←── africa-coord-bus
        │
        ▼
A2A + ADK agents
        │
        ▼
Streamlit coordination interfaces
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each layer is replaceable. The models are replaceable. The MCP servers are replaceable. What persists is the coordination architecture.&lt;/p&gt;

&lt;h2&gt;
  
  
  Install
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install &lt;/span&gt;africa-coord-bus
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Source: &lt;a href="https://github.com/gabrielmahia/africa-coord-bus" rel="noopener noreferrer"&gt;github.com/gabrielmahia/africa-coord-bus&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The coordination gap was the last missing layer. It's now installable.&lt;/p&gt;

&lt;p&gt;Source: &lt;a href="https://pypi.org/project/africa-coord-bus/" rel="noopener noreferrer"&gt;africa-coord-bus on PyPI&lt;/a&gt;&lt;/p&gt;

</description>
      <category>mcp</category>
      <category>africa</category>
      <category>coordination</category>
      <category>python</category>
    </item>
    <item>
      <title>Still Testing APIs Like It's 2022? Here's Everything That Changed"</title>
      <dc:creator>Imran Al Munyeem</dc:creator>
      <pubDate>Mon, 03 Aug 2026 10:00:00 +0000</pubDate>
      <link>https://dev.to/imranalmunyeem/still-testing-apis-like-its-2022-heres-everything-that-changed-28l8</link>
      <guid>https://dev.to/imranalmunyeem/still-testing-apis-like-its-2022-heres-everything-that-changed-28l8</guid>
      <description>&lt;p&gt;If you learnt Postman a few years ago and have been coasting on that knowledge, an uncomfortable amount of it is now wrong — not "slightly dated" wrong, but "the tab you're looking for doesn't exist" wrong.&lt;/p&gt;

&lt;p&gt;I know because I wrote a Postman guide in 2022 and recently rewrote it as a full book. Here's the changelog of everything that broke, moved, or appeared — the field guide I wish I'd had.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. The Tests tab is gone
&lt;/h2&gt;

&lt;p&gt;The old &lt;strong&gt;Tests&lt;/strong&gt; and &lt;strong&gt;Pre-request Script&lt;/strong&gt; tabs were merged into a single &lt;strong&gt;Scripts&lt;/strong&gt; tab with two sections: &lt;strong&gt;Pre-request&lt;/strong&gt; (runs before the request) and &lt;strong&gt;Post-response&lt;/strong&gt; (runs after — this is where your tests live now).&lt;/p&gt;

&lt;p&gt;Your code is unchanged — same &lt;code&gt;pm.test&lt;/code&gt;, same Chai assertions, same snippets — only the geography moved:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Exactly the same as it ever was, just under Scripts → Post-response&lt;/span&gt;
&lt;span class="nx"&gt;pm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Status code is 200&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;function &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;pm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;to&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;have&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every tutorial screenshot showing a Tests tab is from the old world. The concepts transfer completely.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Newman has an official successor
&lt;/h2&gt;

&lt;p&gt;Newman — the beloved open-source CLI runner — is no longer the only way to run collections from a terminal. The official &lt;strong&gt;Postman CLI&lt;/strong&gt; authenticates with a Postman API key and can run collections &lt;strong&gt;straight from your workspace, no export step&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;postman login &lt;span class="nt"&gt;--with-api-key&lt;/span&gt; &lt;span class="nv"&gt;$POSTMAN_API_KEY&lt;/span&gt;
postman collection run 12345678-abcd-efgh-ijkl-9876543210ab
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It also pushes results back to Postman's cloud as a shareable run report.&lt;/p&gt;

&lt;p&gt;Newman still works fine and its htmlextra HTML reports remain unmatched — but there's a hard compatibility line to know: &lt;strong&gt;Newman only supports collection format v2/v2.1&lt;/strong&gt;. Postman v12 introduced a v3 (YAML) format for its Git-native workflows, and Newman cannot run it. New pipeline? Start with the Postman CLI. Existing Newman pipeline? It keeps working — just export v2.1.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Your Jenkins tutorial will not work
&lt;/h2&gt;

&lt;p&gt;The classic "install JDK 8, run &lt;code&gt;java -jar jenkins.war&lt;/code&gt;" instructions are dead: &lt;strong&gt;modern Jenkins requires Java 21&lt;/strong&gt; (recent LTS lines dropped Java 17 and older). If you're standing up a CI box today, grab Temurin 21 from adoptium.net first, or Jenkins simply won't start.&lt;/p&gt;

&lt;p&gt;Better yet, notice that for many teams the Jenkins box is now optional — a 25-line GitHub Actions workflow runs your collection on every push and every night, with no server to patch:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;API Tests&lt;/span&gt;
&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;push&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;schedule&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;cron&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;0&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;2&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*"&lt;/span&gt;
&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;postman-tests&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/setup-node@v4&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;node-version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;lts/*"&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npm install -g newman newman-reporter-htmlextra&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;newman run collections/MyCollection.postman_collection.json -r cli,htmlextra&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  4. There's an AI in the footer
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Postbot&lt;/strong&gt; generates tests from plain-English prompts, adds baseline tests to a whole collection in one action, repairs failing scripts, documents requests, and visualises responses. It's genuinely useful and genuinely dangerous in the same specific way: it writes assertions from the response your API &lt;em&gt;currently gives&lt;/em&gt; — so if the current behaviour is a bug, the bug becomes the expected result. Generate freely; review against the spec, not the response. (I wrote a whole piece on this failure mode.)&lt;/p&gt;

&lt;p&gt;Postman also added AI request types (test your LLM-backed endpoints like any other), Agent Mode, and an MCP server — the platform is clearly betting that agents will be first-class API consumers.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Your practice APIs died
&lt;/h2&gt;

&lt;p&gt;Heroku ended its free tier, taking a generation of tutorial APIs with it. If a guide points you at &lt;code&gt;something.herokuapp.com&lt;/code&gt;, expect a dead link. &lt;strong&gt;JSONPlaceholder&lt;/strong&gt; (&lt;code&gt;jsonplaceholder.typicode.com&lt;/code&gt;) remains free, signup-less, and reliable — or create a mock server inside Postman itself from your saved examples.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. The scripting API moved on politely
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;postman.setNextRequest(null)&lt;/code&gt; still works, but the current form is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;pm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;execution&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setNextRequest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// stop the run&lt;/span&gt;
&lt;span class="nx"&gt;pm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;execution&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setNextRequest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Delete user&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// or jump to a request&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same story across the &lt;code&gt;pm&lt;/code&gt; API: old code keeps running, new code should use the new names.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. Secrets got first-class treatment
&lt;/h2&gt;

&lt;p&gt;Two things every team should know: variables now have a &lt;strong&gt;secret type&lt;/strong&gt; (masked on screen), and — the one that catches people — &lt;strong&gt;initial values sync to Postman's servers and are shared with collaborators, while current values stay on your machine.&lt;/strong&gt; Real credentials go in current values only, or in Postman Vault (encrypted, local, never synced). The classic leak is a Bearer token pasted into an initial value "just for a second," then synced, then forked into a public workspace.&lt;/p&gt;

&lt;h2&gt;
  
  
  The takeaway
&lt;/h2&gt;

&lt;p&gt;None of this changed what good API testing &lt;em&gt;is&lt;/em&gt; — five layers of assertions, negative cases, deterministic suites, CI enforcement. What changed is the tooling around it, and the tooling changed enough that 2022 muscle memory now produces broken pipelines and missing tabs. Update the muscle memory; keep the principles.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This is the story behind my free, open-source book *&lt;/em&gt;&lt;a href="https://imranalmunyeem.github.io/api-testing-using-postman/" rel="noopener noreferrer"&gt;API Testing Using Postman: The Practical Guide to Modern API Testing&lt;/a&gt;** — a 2022 guide rewritten end-to-end for how we test now. Read online, grab the PDF/EPUB, or contribute on &lt;a href="https://github.com/imranalmunyeem/api-testing-using-postman" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;.*&lt;/p&gt;

&lt;p&gt;&lt;em&gt;I'm a PhD researcher in Computer Science at Nottingham Trent University working on cybersecurity and AI-assisted security testing. More at &lt;a href="https://imranalmunyeem.com" rel="noopener noreferrer"&gt;imranalmunyeem.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>testing</category>
      <category>postmanapi</category>
      <category>qa</category>
    </item>
    <item>
      <title>From Retrospective Liability to Proactive Capability — A Path to Crypto-Agility and Compliance with Embedded Security Requirements</title>
      <dc:creator>Art Keller</dc:creator>
      <pubDate>Mon, 03 Aug 2026 09:55:51 +0000</pubDate>
      <link>https://dev.to/artkeller/from-retrospective-liability-to-proactive-capability-a-path-to-crypto-agility-and-compliance-with-3lin</link>
      <guid>https://dev.to/artkeller/from-retrospective-liability-to-proactive-capability-a-path-to-crypto-agility-and-compliance-with-3lin</guid>
      <description>&lt;p&gt;NON-PAPER&lt;/p&gt;

&lt;p&gt;&lt;em&gt;This paper is distributed solely for discussion purposes. It reflects an independent analytical perspective and is not presented on behalf of any organization, body, or regulatory authority.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Setting the Context
&lt;/h2&gt;

&lt;p&gt;The convergence of the EU Cyber Resilience Act, NIS2, DORA, and national implementations such as BSI TR-02102 creates a compliance landscape for which many organizations with long-standing embedded product lines are structurally unprepared - not due to a lack of technical solutions, but due to a lack of a common approach that integrates technical capability, budgetary autonomy, and risk appetite into a single, actionable timeline.&lt;/p&gt;

&lt;p&gt;This is not a failure of any single function, company, or industry. It is the predictable result of three decades of technological decisions, each made under different circumstances, that are now converging on a regulatory deadline that does not distinguish between historical legacy issues and current negligence. Treating this convergence as a blame game is counterproductive: it encourages concealment over disclosure and inaction over incremental progress.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Diagnosis, Without Blame
&lt;/h2&gt;

&lt;p&gt;Three institutional patterns recur across organizations, regardless of industry or size:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Technical knowledge of exposure (legacy silicon without cryptographic isolation, insufficient memory for PQC migration, lack of side-channel protection) typically exists long before action is taken - the gap lies in decision-making authority and funding, not in awareness.&lt;/li&gt;
&lt;li&gt;Risk-averse institutional behavior, when acting rationally within the framework of its own incentives, tends to discourage disclosure rather than facilitate remediation, since disclosure without a funded remediation plan creates risk without offering corresponding risk mitigation.&lt;/li&gt;
&lt;li&gt;Capital allocation processes designed for predictable, limited costs receive compliance issues reported either as “resolved” (which underestimates the residual risk) or as “critical” (without actionable cost or time estimates) - both of which are poor bases for decision-making.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The result is a structural stalemate: Each pattern is rational in and of itself, and the overall result is stagnation.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. The Manufacturing Dimension: Disruption as Capital Destruction
&lt;/h2&gt;

&lt;p&gt;For manufacturing companies - particularly in the Industry 4.0 context, where product lines are tied to long depreciation cycles, specialized supply chains, and capital-intensive tooling - this stalemate takes on a specific and more acute form.&lt;/p&gt;

&lt;p&gt;The prevailing vocabulary surrounding compliance-driven technological change draws heavily on the term “disruption” - a term that became popular to describe market entry, not asset management. When applied to an existing inventory of manufacturing equipment or industrial products in the field, disruption is not a neutral or even positive event:&lt;br&gt;
 It is the forced write-off of capital that has not yet reached its intended useful life. A product discontinuation triggered by the inability to meet a cryptographic compliance deadline is not innovation - it is the destruction of prior investments, often exacerbated by the costs of a rushed replacement cycle carried out without the lead time such transitions typically require.&lt;/p&gt;

&lt;p&gt;This distinction is important because it changes the nature of the decision facing manufacturing companies. The choice is not “innovate or stagnate” - the framing suggested by the rhetoric of disruption - but rather “consciously shape a forced transition, or have it imposed at the moment of regulatory or market-driven failure.” Paranoia in the face of a real, credible risk of discontinuation is not an overreaction; it is an accurate assessment of the situation. What is missing is not risk awareness, but a path that treats the transition as a managed capital process rather than a binary cliff edge.&lt;/p&gt;

&lt;p&gt;This is precisely where interim, verifiable technical measures - border protection approaches that extend the compliance-compliant service life of existing hardware without a complete redesign - play their true role: not as a permanent substitute for modernization, but as a deliberate deceleration of an otherwise forced, value-destroying disruption into a planned, capital-preserving transition.&lt;/p&gt;

&lt;p&gt;One caveat is in order here: The characterization of the technical solution as “a coprocessor solves it” is itself a simplification that this paper does not wish to accept unquestioningly. A hardware-based protective measure eliminates the cryptographic exposure of the affected module. It does not resolve legacy issues - expiring certificate trust chains, firmware update infrastructure built for the old architecture, or other non-cryptographic obsolescence within the same product generation - and it does not, by itself, answer the question of who bears the resulting costs. The following case study is deliberately expanded to highlight these gaps, rather than presenting the technical solution as a complete resolution.&lt;/p&gt;

&lt;p&gt;There is another dimension that pure cost accounting does not capture: customer inertia is not an obstacle to be overcome, but rather the actual value being purchased. An OEM customer who selects a component supplier is, to a large extent, purchasing the ability to not have to rearchitect their own system according to the supplier’s schedule. A corrective measure that forces the customer into cascading requalification, redesign, or downtime not only costs money - it breaks the specific promise upon which the original purchase decision was based. From the customer’s perspective, a technically correct solution that forces the customer to disrupt its own operations in order to remain compliant is indistinguishable from the end-of-life that it was actually intended to prevent. To put it plainly, the design goal is not “compliant” - but rather “compliant without forcing the customer to take action.”&lt;/p&gt;

&lt;h2&gt;
  
  
  4. A Forward-Looking Framework
&lt;/h2&gt;

&lt;p&gt;Breaking the stalemate described in Section 2 - and avoiding the destructive dynamic described in Section 3 - requires treating technical remediation, disclosure practices, and capital planning as a single coordinated timeline, rather than as sequential hand-offs in which each side waits for clarity from the other.&lt;/p&gt;

&lt;p&gt;A workable approach relies on three parallel, mutually reinforcing lines of action:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Technical&lt;/strong&gt;: Creating an inventory against a minimum crypto-agility baseline (dedicated signing hardware, side-channel protection class, PQC-compatible memory headroom). Where a complete redesign within the remaining product lifecycle cannot be economically justified, implementation of interim protective measures - explicitly documented as interim, not presented as a permanent architecture, and, wherever possible, limited to drop-in compatibility with the existing customer interface.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Disclosure Policy&lt;/strong&gt;: A documented, dated, and funded remediation plan is treated as the primary risk-mitigation measure, since, according to most current regulatory interpretations, a credible remediation timeline reduces risk more effectively than silence.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Capital Planning&lt;/strong&gt;: Replacing binary “resolved/critical” reporting with milestone-based reporting - staggered budget release tied to demonstrated technical progress, rather than full upfront allocation or withholding funds until complete certainty is achieved.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  5. Hard Milestones (illustrative, 24-month horizon)
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Time Period&lt;/th&gt;
&lt;th&gt;Technical Milestone&lt;/th&gt;
&lt;th&gt;Disclosure Milestone&lt;/th&gt;
&lt;th&gt;Funding Milestone&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Months 1–6&lt;/td&gt;
&lt;td&gt;Assessment &amp;amp; risk classification completed&lt;/td&gt;
&lt;td&gt;Framework for remediation disclosure established&lt;/td&gt;
&lt;td&gt;Base funding released&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Months 7–12&lt;/td&gt;
&lt;td&gt;Interim safeguards implemented on highest-risk lines&lt;/td&gt;
&lt;td&gt;First remediation plan documented&lt;/td&gt;
&lt;td&gt;Milestone-linked funding, Tranche 2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Months 13–18&lt;/td&gt;
&lt;td&gt;PQC migration piloted for new developments&lt;/td&gt;
&lt;td&gt;Proactive regulatory engagement, where applicable&lt;/td&gt;
&lt;td&gt;Funding Tranche 3, linked to pilot results&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Months 19–24&lt;/td&gt;
&lt;td&gt;Crypto agility baseline achieved for prioritized product lines&lt;/td&gt;
&lt;td&gt;Compliance status documented for audit&lt;/td&gt;
&lt;td&gt;Transition to standard budgeting&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  6. Concluding Remarks
&lt;/h2&gt;

&lt;p&gt;This framework does not resolve the underlying tension between historical underinvestment and current regulatory deadlines - no framework can retroactively fund three decades of deferred maintenance. What it offers is a way to channel this tension into a limited, controllable, capital-preserving process, rather than an unlimited, value-destroying one. The alternative to a structured path is not the absence of costs - it is the same costs, incurred later, under worse conditions, framed as disruption rather than managed as a transition.&lt;/p&gt;




&lt;h2&gt;
  
  
  APPENDIX  -  Illustrative Case Study
&lt;/h2&gt;

&lt;p&gt;The following narrative is fictional and composite. It does not describe a real company, and no resemblance to any named or identifiable organization is intended. It serves to concretely illustrate the framework of this paper, not to report on an actual transformation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Case: A fictional medium-sized industrial company (“Vantric Industrietechnik GmbH &amp;amp; Co. KG”)
&lt;/h2&gt;

&lt;p&gt;Vantric is a fictional, family-run manufacturer of industrial control and automation components - the kind of high-end medium-sized business commonly found in the German industrial landscape:&lt;br&gt;
 approximately 1,400 employees, three generations of family ownership, and a product portfolio consisting of connectivity modules, sensor gateways, and PLC peripherals with typical field lifespans of 12–18 years. Revenue is solid, but margins are thin in a highly competitive OEM supply market. The company is neither large enough to absorb a compliance shock unnoticed nor small enough to escape the regulatory scope.&lt;/p&gt;

&lt;h2&gt;
  
  
  Month 0  -  The Trigger
&lt;/h2&gt;

&lt;p&gt;The trigger was not a security incident. It was a procurement questionnaire from a customer - a Tier-1 automotive supplier - that required, as a standard attachment to the request for proposal, a documented PQC migration roadmap and a CRA declaration of conformity for a gateway module that Vantric had been shipping largely unchanged since 2014. The engineering manager in charge was unable to answer the questionnaire. As it turned out, no one in the entire company could.&lt;/p&gt;

&lt;h2&gt;
  
  
  Months 1–4  -  The Stalemate, Exactly as Predicted
&lt;/h2&gt;

&lt;p&gt;The pattern described in Section 2 of this paper played out almost exactly as described. The head of embedded development, Mr. K., had already pointed out the lack of hardware-isolated key storage in the underlying silicon base two years earlier in an internal memo - filed away, acknowledged, but not funded. Upon reviewing the request for proposals, the legal department recommended not disclosing the gap until “further internal review,” out of concern that any written admission could become evidence in a future liability case. The CFO, faced with a cost estimate that ranged from “manageable” to “existentially threatening” depending on whether a complete redesign or an interim measure was assumed, postponed the decision until the next fiscal quarter - a pattern of delay that the finance team recognized from two earlier, unrelated compliance cycles.&lt;/p&gt;

&lt;p&gt;Mr. K. considered resigning. He didn’t, but the internal blame game - “who allowed this to happen?” - consumed most of a quarter that was actually supposed to be devoted to remediation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Month 5  -  The Reframing
&lt;/h2&gt;

&lt;p&gt;What changed was not new information - it was a reframing introduced by an external consultant who had originally been brought in for an independent GDPR matter. He pointed out that the company’s own product lifecycle data (average field service life: 14 years) meant that a complete portfolio redesign was neither necessary nor economically viable for the majority of the affected product line.&lt;br&gt;
 About 60% of the units shipped still had a projected remaining service life of 4–7 years - long enough that an interim protective measure (a small, security-hardened coprocessor, to be added during the next scheduled hardware revision) would keep them compliant until the end of their natural life. The remaining 40%, concentrated in newer product lines with a remaining lifespan of over 10 years, did indeed warrant a redesign - but at the normal development pace, not in a panic.&lt;/p&gt;

&lt;p&gt;This reframing achieved two things at once: It gave the legal department a defensible, documented position (“a funded, dated remediation plan, differentiated by product cohort”), and it gave the CFO a specific number rather than a range anchored in “existential threat.”&lt;/p&gt;

&lt;h2&gt;
  
  
  Months 6–18  -  Implementation, with Friction
&lt;/h2&gt;

&lt;p&gt;The rollout did not go smoothly. A supplier of the proposed coprocessor experienced a six-week supply bottleneck, which pushed the Q3 milestone back to Q4. A product manager whose bonus was tied to a now-delayed product refresh - unrelated to compliance work - advocated - unsuccessfully - for downgrading the coprocessor development in favor of his roadmap. The works council raised valid questions about whether the interim measure constituted a permanent downgrade in build quality, which required two additional weeks of technical briefings to clarify the matter.&lt;/p&gt;

&lt;p&gt;None of this derailed the plan. It delayed it by about one quarter compared to the original 24-month timeline - which the steering committee had deliberately factored in as a buffer from the outset by budgeting 27 months instead of 24, precisely for this reason.&lt;/p&gt;

&lt;h2&gt;
  
  
  Months 12–20  -  Costs Don’t Disappear; They Just Shift
&lt;/h2&gt;

&lt;p&gt;The coprocessor solved the cryptographic capacity issue for the affected gateway module. It did not, however, solve two other problems that arose almost immediately once the solution reached the commercial side of the business.&lt;/p&gt;

&lt;p&gt;First, the additional cost increase of €1.85 per unit could not, in practice, be absorbed across the entire customer base - low-margin OEM contracts with multi-year fixed-price commitments meant that the increase either had to be absorbed (which further eroded the already tight margin) or passed on through contract renegotiation. The sales management, faced with the prospect of having to renegotiate fixed-price agreements with several long-standing customers in the middle of their contract terms, openly resisted: The internal argument was not that the solution was wrong, but that “no one sells a security feature that no one asked for at a price premium that no one budgeted for.”&lt;br&gt;
 When the price pass-through was announced, two smaller customers demanded competing new quotes instead of simply accepting the increase - one ultimately stayed, citing the documented remediation plan itself as a differentiating factor; the other did not stay and was lost to a competitor who continued to deliver the unupgraded legacy design.&lt;/p&gt;

&lt;p&gt;Second, and less visible internally, the solution generated costs on the customer side that Vantrics’ sales team had not originally factored into the discussion. Customers who operated the affected gateways as embedded components of their own certified systems faced their own recertification costs - renewed conformity assessments, in some cases recertification of downstream products that contained the Vantrics module as a subassembly, as well as planning for field retrofits with associated downtime. For customers in regulated industrial environments, this was no trivial burden to pass on; several requested extended transition periods, and one asked directly, “Who’s going to finance this on our end?” - a question to which Vantrics’ sales team had no prepared answer.&lt;/p&gt;

&lt;p&gt;The solution, developed over about two additional months of commercial and technical negotiations, was shaped by a requirement that the customer support team had insisted on from the very beginning: Whatever solution was chosen, it had to be drop-in compatible with the form factor, pinout, and firmware interface of the existing gateway. The customers’ own certified systems had been built and certified around Vantrics’ original module; anything that would have forced the customer to modify its own architecture - a new mechanical footprint, a changed communication protocol, a firmware API break - would have shifted the costs from “a single item” to “an entire program” and triggered precisely the kind of cascading re-certification on the customer’s side that no price negotiation could have offset. An early technical proposal - a more powerful but pin-incompatible replacement module - was rejected for this reason alone, even though it would have been cheaper per unit than the coprocessor approach that was ultimately delivered.&lt;/p&gt;

&lt;p&gt;The chosen solution - the coprocessor, added during the next scheduled hardware revision - adhered to this requirement precisely because it operated behind the existing interface: from the customer system’s perspective, nothing changed except for a compliance certificate. That was what made the financing discussions with the bank and the tiered pricing with customers viable in the first place - the underlying business case presented to every stakeholder, both internal and external, boiled down to the same sentence: Your operations do not need to change for this to be resolved. Vantric’s existing primary bank agreed to extend the company’s investment credit line specifically against the documented, milestone-based remediation plan - the same plan that had addressed the legal department’s disclosure concerns became the basis for the loan, as it offered the bank a limited, time-bound risk rather than an open-ended one. For the largest affected customers, an optional extended service contract was offered that bundled the hardware update with integration support from Vantric, thereby transforming a pure cost pass-through into a service offering with its own margin. The pricing itself was phased in over the remainder of the contract term rather than applied immediately.&lt;/p&gt;

&lt;p&gt;Where the statement “Your operations do not need to change” did not apply - in the case of the discontinued third-party wireless module mentioned below, for which there was no drop-in equivalent -  the company was honest that genuine disruption with real cascading costs on the customer side was unavoidable, and treated it as a separate, slower-moving, explicitly labeled exception rather than incorporating it into the smooth narrative of the main program.&lt;/p&gt;

&lt;p&gt;This didn’t make the transition free for anyone. It made the costs visible, spread out, and manageable - which is not the same as solved.&lt;/p&gt;

&lt;h2&gt;
  
  
  Month 24 (effectively Month 27)  -  Result
&lt;/h2&gt;

&lt;p&gt;Eighteen months after the initial triggering RFP, Vantric submitted a documented, differentiated remediation plan to its key customers - ahead of the customer’s internal deadline and before the 2030 threshold affecting the majority of the affected product cohort. The coprocessor-based interim measure was delivered to the 60% legacy cohort at an additional cost of approximately €1.85 per unit on the bill of materials - compared to an internal estimate that a complete redesign of the same cohort would have cost about 40 times the total budget of the coprocessor program in development hours and re-qualification alone.&lt;/p&gt;

&lt;p&gt;Mr. K. did not resign. Eighteen months later, he was granted the budgetary authority he had lacked at the outset - not as a reward, but because the episode had concretely demonstrated that the gap between “Engineering knows” and “Engineering can act” was the actual cost driver, not the underlying technical debt itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  What This Case Does Not Resolve
&lt;/h2&gt;

&lt;p&gt;The coprocessor program addressed the cryptographic limitation in one product line. The following remained unaffected: the firmware update infrastructure for older field units, which had already been implemented prior to the fix; a separate, still-unresolved issue regarding a discontinued third-party radio module in another product line, for which no equivalent interim measure exists - and where, unlike in the main program, customer disruption could not be avoided and had to be managed as a genuine, cascading exception rather than being smoothed over. Also unaffected was the commercial reality that every unit of cost avoided by an interim measure at Vantric was, in some form, either absorbed by Vantric’s margin, passed on to a customer, or financed as debt - it did not simply disappear. &lt;br&gt;
The case is presented as an illustration of a functional process, not as proof that the underlying costs of decades of deferred investments can be eliminated through technology.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the Case Illustrates
&lt;/h2&gt;

&lt;p&gt;Nothing about this synthetic case required novel technology, a large budget, or a heroic individual. It required three things that happened in parallel rather than sequentially: an honest, nuanced assessment (not “everything is broken” or “nothing is broken,” but a distribution across cohorts); a culture of disclosure that treats an outdated plan as protective rather than incriminating; and a capital process willing to release funding against milestones rather than demanding complete certainty up front. The friction - supplier delays, internal politics, works council proceedings, sales resistance, customer costs, and the one genuine exception that could not be smoothed over - was real and was deliberately factored in: The framework of this paper is not a guarantee against friction, but merely a structure within which ordinary friction does not become a permanent delay, and within which the disruption that does occur is chosen and planned rather than forced.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&amp;gt; Non-Paper for discussion purposes. Independent analytical perspective.&lt;/strong&gt;&lt;/p&gt;

</description>
      <category>security</category>
      <category>cybersecurity</category>
      <category>embedded</category>
      <category>compliance</category>
    </item>
    <item>
      <title>My Terraform Runner Destroyed Itself Mid-Apply</title>
      <dc:creator>david</dc:creator>
      <pubDate>Mon, 03 Aug 2026 09:54:57 +0000</pubDate>
      <link>https://dev.to/dwoitzik/my-terraform-runner-destroyed-itself-mid-apply-2995</link>
      <guid>https://dev.to/dwoitzik/my-terraform-runner-destroyed-itself-mid-apply-2995</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://woitzik.dev/blog/atlantis-terraform-destroyed-itself-mid-apply/" rel="noopener noreferrer"&gt;woitzik.dev&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;em&gt;Disclosure: This post contains Amazon affiliate links (marked with *). If you buy through them, I earn a small commission at no extra cost to you. I only link gear I actually own and use daily.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;On 2026-07-04, Atlantis applied a Terraform change that shut down the VM it was running on. The Terraform runner destroyed its own runtime environment mid-apply.&lt;/p&gt;

&lt;p&gt;This is the story of a circular dependency that doesn't show up in &lt;code&gt;terraform plan&lt;/code&gt;, why &lt;code&gt;bpg/proxmox&lt;/code&gt; can't always update attributes in-place, and how moving Atlantis from a k3s Deployment to a dedicated LXC container eliminated the hazard entirely.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://github.com/dwoitzik/homelab-infrastructure" rel="noopener noreferrer"&gt;View the complete homelab infrastructure source on GitHub 🐙&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The Setup
&lt;/h2&gt;

&lt;p&gt;Atlantis was deployed as a k3s Deployment in the &lt;code&gt;apps&lt;/code&gt; namespace, running on one of three k3s VMs managed by the same Proxmox Terraform stack. The Proxmox Terraform configuration (&lt;code&gt;terraform/stacks/proxmox/&lt;/code&gt;) defines all three k3s VMs, their CPU, memory, disk, and boot settings.&lt;/p&gt;

&lt;p&gt;The circular dependency: Atlantis runs on k3s VMs → Terraform manages k3s VMs → Atlantis applies Terraform changes to k3s VMs.&lt;/p&gt;

&lt;p&gt;In practice, this was safe for most changes. &lt;code&gt;memory&lt;/code&gt;, &lt;code&gt;disk.size&lt;/code&gt;, &lt;code&gt;cpu.cores&lt;/code&gt; — these can all be updated in-place by &lt;code&gt;bpg/proxmox&lt;/code&gt; without stopping the VM. But certain attributes require a full VM shutdown-and-restart cycle. Specifically: &lt;code&gt;cpu.units&lt;/code&gt; (scheduling priority) and &lt;code&gt;serial_device&lt;/code&gt; configuration.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Kill Shot
&lt;/h2&gt;

&lt;p&gt;The Terraform diff for a &lt;code&gt;cpu.units&lt;/code&gt; change looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"proxmox_virtual_machine"&lt;/span&gt; &lt;span class="s2"&gt;"vm_srv_k3s_11"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;# ...&lt;/span&gt;
  &lt;span class="nx"&gt;cpu&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;units&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;2048&lt;/span&gt;  &lt;span class="c1"&gt;# 2x scheduling priority over LXCs&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When &lt;code&gt;bpg/proxmox&lt;/code&gt; detects a change to &lt;code&gt;cpu.units&lt;/code&gt;, it can't hot-apply it. The provider issues a &lt;code&gt;qmshutdown&lt;/code&gt; via the Proxmox API, waits for the VM to stop, applies the change, then starts the VM again.&lt;/p&gt;

&lt;p&gt;On 2026-07-04, a PR changed &lt;code&gt;cpu.units&lt;/code&gt; on one of the k3s VMs. Atlantis picked up the PR, ran &lt;code&gt;terraform plan&lt;/code&gt;, showed the diff (in-place update), and ran &lt;code&gt;terraform apply&lt;/code&gt;. The &lt;code&gt;bpg/proxmox&lt;/code&gt; provider sent &lt;code&gt;qmshutdown&lt;/code&gt; to the Proxmox API for the VM running the Atlantis pod.&lt;/p&gt;

&lt;p&gt;The VM shut down. The Atlantis pod was killed. The Terraform apply was interrupted mid-execution. Kubernetes rescheduled the pod on a different node, but the apply state was lost.&lt;/p&gt;

&lt;p&gt;The same thing happened again on a &lt;code&gt;serial_device&lt;/code&gt; attribute change — another attribute that requires a VM shutdown. Two occurrences, same root cause: the Terraform runner was managing the infrastructure it depended on for its own execution.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why &lt;code&gt;terraform plan&lt;/code&gt; Doesn't Catch This
&lt;/h2&gt;

&lt;p&gt;The circular dependency isn't expressed in the Terraform configuration. Atlantis's Pod spec doesn't reference the Proxmox VMs, and the Proxmox VMs don't reference Atlantis. Terraform sees two independent resource graphs. The dependency is physical, not declarative — Atlantis runs &lt;em&gt;on&lt;/em&gt; the VMs, but Terraform doesn't know that.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;terraform plan&lt;/code&gt; shows "will update in-place" for &lt;code&gt;cpu.units&lt;/code&gt;. It doesn't know that "update in-place" means "shut down the VM first." That behavior is a provider implementation detail, not something Terraform's planning phase understands.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;lifecycle.ignore_changes&lt;/code&gt; block can prevent specific attributes from being planned, but that's a workaround, not a fix. You'd be ignoring a real change to avoid a structural hazard.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Fix: Move Atlantis Off k3s
&lt;/h2&gt;

&lt;p&gt;The fix was ADR-012: move Atlantis from a k3s Deployment to a dedicated LXC container (&lt;code&gt;ct-srv-atlantis-01&lt;/code&gt;, VMID 204, IP &lt;code&gt;10.0.20.250&lt;/code&gt;).&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="c1"&gt;# terraform/stacks/proxmox/lxc.tf&lt;/span&gt;
&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"proxmox_virtual_machine"&lt;/span&gt; &lt;span class="s2"&gt;"ct_srv_atlantis_01"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;# Dedicated LXC — NOT managed by the same Terraform stack&lt;/span&gt;
  &lt;span class="c1"&gt;# Atlantis manages OTHER stacks, but its own container is outside the scope&lt;/span&gt;
  &lt;span class="nx"&gt;vm_id&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;204&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"ct-srv-atlantis-01"&lt;/span&gt;
  &lt;span class="nx"&gt;node_name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"pve"&lt;/span&gt;
  &lt;span class="c1"&gt;# ...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The key difference: the Atlantis LXC is still defined in the Proxmox Terraform stack, but it's &lt;em&gt;never&lt;/em&gt; managed by Atlantis itself. The &lt;code&gt;atlantis.yaml&lt;/code&gt; repo config whitelists only specific repos and directories:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;repos&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;github.com/dwoitzik/homelab-infrastructure&lt;/span&gt;
    &lt;span class="na"&gt;allowed_overrides&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;apply_requirements&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;delete_source_branch_on_merge&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;apply_requirements&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;approved&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;mergeable&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;projects&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;dir&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;terraform/stacks/network&lt;/span&gt;
        &lt;span class="na"&gt;workspace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;default&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;dir&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;terraform/stacks/cloudflare&lt;/span&gt;
        &lt;span class="na"&gt;workspace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;default&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;dir&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;terraform/stacks/garage&lt;/span&gt;
        &lt;span class="na"&gt;workspace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;default&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Notice: &lt;code&gt;terraform/stacks/proxmox/&lt;/code&gt; is &lt;strong&gt;not&lt;/strong&gt; listed. Atlantis can plan and apply network, cloudflare, and garage changes — but never Proxmox changes. Proxmox changes go through a separate review process, or I apply them manually after careful review.&lt;/p&gt;

&lt;p&gt;This breaks the circular dependency structurally: Atlantis manages everything &lt;em&gt;except&lt;/em&gt; the infrastructure it runs on.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Ansible Layer
&lt;/h2&gt;

&lt;p&gt;The Atlantis LXC runs via Ansible, not k3s:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# ansible/roles/atlantis/tasks/main.yml&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Deploy Atlantis via Docker Compose&lt;/span&gt;
  &lt;span class="na"&gt;community.docker.docker_compose_v2&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;project_src&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/opt/atlantis&lt;/span&gt;
    &lt;span class="na"&gt;state&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;present&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The custom Dockerfile includes the Proxmox self-signed CA cert (&lt;code&gt;pve-root-ca.crt&lt;/code&gt;) so Atlantis can talk to the Proxmox API over HTTPS:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="s"&gt; ghcr.io/runatlantis/atlantis:v0.30.0&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; pve-root-ca.crt /usr/local/share/ca-certificates/pve-root-ca.crt&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;update-ca-certificates
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Proxmox API token, Cloudflare API token, and MikroTik credentials are all stored in Ansible Vault and injected via Docker Compose environment variables.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Pattern
&lt;/h2&gt;

&lt;p&gt;Any Terraform runner that manages the infrastructure it runs on has this hazard. In a cloud environment, it's less obvious because &lt;code&gt;terraform apply&lt;/code&gt; against an Azure VM doesn't restart the VM — Azure handles in-place updates at the platform level. But the same structural dependency exists: an Atlantis instance running on an Azure VM that manages that VM's NSG, disk, or network interface.&lt;/p&gt;

&lt;p&gt;The clean fix is always the same: the runner manages everything &lt;em&gt;except&lt;/em&gt; itself. If that's not possible, &lt;code&gt;lifecycle { ignore_changes }&lt;/code&gt; on attributes that trigger restarts is the minimum viable mitigation.&lt;/p&gt;




&lt;p&gt;The same pattern applies to CI/CD runners in enterprise environments. A self-hosted GitHub Actions runner managing its own host's infrastructure via Terraform has the identical circular dependency. The fix is the same: separate the runner's infrastructure from the infrastructure it manages, even if they share the same cloud account.&lt;/p&gt;



</description>
      <category>terraform</category>
      <category>gitops</category>
      <category>homelab</category>
      <category>debugging</category>
    </item>
    <item>
      <title>5 Words that Describe Me</title>
      <dc:creator>Brian Kim</dc:creator>
      <pubDate>Mon, 03 Aug 2026 09:54:36 +0000</pubDate>
      <link>https://dev.to/bibimbop123/5-words-that-describe-me-1nil</link>
      <guid>https://dev.to/bibimbop123/5-words-that-describe-me-1nil</guid>
      <description>&lt;h1&gt;
  
  
  Brian Kim
&lt;/h1&gt;

&lt;h1&gt;
  
  
  5 Words that Describe Me
&lt;/h1&gt;

&lt;h2&gt;
  
  
  Determined
&lt;/h2&gt;

&lt;p&gt;I am not one to quit on what I set my goals on. This can be seen through wrestling. Wrestling has been difficult for me both physically and mentally. Since freshman year, my goal was to be in Varsity wrestling. I have lost matches and won matches, so I know it is not always a smooth ride to success. Many hours of training both during season and off season have been dedicated to wrestling. I am determined because I know that hard work will pay off in the end.&lt;/p&gt;

&lt;h2&gt;
  
  
  Disciplined
&lt;/h2&gt;

&lt;p&gt;Besides just wrestling, I have been a three season athlete for two years. I have participated in cross country, wrestling, and track. These sports have disciplined me in a way that I am able understand without hardships there cannot be any rewards; “no pain, no gain”. In addition, I am mentally disciplined. I know how to balance my priorities between school and sports. Although academics may not be my strong suit, I take it as seriously as the sports I have partaken in.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reliable
&lt;/h2&gt;

&lt;p&gt;As a team athlete, I need to be reliable. My athletic performance is a good indicator, my wrestling season is one way people put their trust in me. Such as the coaches and my teammates who are there for me during every competition and root for me to win. Furthermore, senior year has been a very ambitious year for me. I have taken several leadership positions and joined a variety of different clubs. For example, I am part of the Kiva club, Odyssey club, Psych club, Flag Football, and the spanish club. These clubs don’t only demonstrate my ambitions, but they represent my reliability and how I can be depended on with a number of different responsibilities.&lt;/p&gt;

&lt;h2&gt;
  
  
  Motivated
&lt;/h2&gt;

&lt;p&gt;By the number of clubs I’ve joined this year, you could already tell that I am ambitious. However, I’m constantly motivated to do more. In this manner, I have again challenged myself this year to work even harder. My dream is to be a physical therapist. I want to help people and I am motivated to do so. I am currently taking Human Anatomy. Despite the headaches and inconveniences, I am reminded of my goal and I won’t stop until i reach it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Capable
&lt;/h2&gt;

&lt;p&gt;I am determined, disciplined, reliable, and motivated. To sum it all up, I am capable. I still believe I have much more to prove, yet I am proud of what i have accomplished so far. Quite honestly, my grades freshman year were disappointing. However, my grades have continued to grow from then. I now have 3.36 gpa. Still, I don’t believe this accurately represents what im capable of. I am not the same person i was freshman year. These characteristics are the result of my 4 years at Stevenson, and i intend to pursue a more victorious future in college.&lt;/p&gt;

</description>
    </item>
    <item>
      <title>AI Is Great at Reasoning. Stop Using It for Workflows.</title>
      <dc:creator>Orel Bello</dc:creator>
      <pubDate>Mon, 03 Aug 2026 09:50:13 +0000</pubDate>
      <link>https://dev.to/orelbello/ai-is-great-at-reasoning-stop-using-it-for-workflows-313c</link>
      <guid>https://dev.to/orelbello/ai-is-great-at-reasoning-stop-using-it-for-workflows-313c</guid>
      <description>&lt;p&gt;More than a year ago, which is practically ancient history in the AI years, I wrote a blog about using AI to build new self-service capabilities.&lt;/p&gt;

&lt;p&gt;It felt like the future.&lt;/p&gt;

&lt;p&gt;We built a self-service action that could create new self-service actions, helping us move faster, reduce bottlenecks, and scale a small Platform Engineering team supporting hundreds of developers.&lt;/p&gt;

&lt;p&gt;One of the most interesting parts was using Amazon Bedrock to generate Terraform code dynamically at runtime, allowing the system to determine how a new cloud resource should be provisioned using our existing Terraform modules.&lt;/p&gt;

&lt;p&gt;It worked.&lt;/p&gt;

&lt;p&gt;It was impressive.&lt;/p&gt;

&lt;p&gt;And… we removed it.&lt;/p&gt;

&lt;p&gt;Looking back, abandoning that approach turned out to be one of the best engineering decisions we made.&lt;/p&gt;

&lt;p&gt;At the time, it felt like an isolated technical decision.&lt;/p&gt;

&lt;p&gt;It wasn’t.&lt;/p&gt;

&lt;p&gt;Recently, we faced a much smaller problem. We wanted to automate the creation of DNS records in Cloudflare through our self-service platform.&lt;/p&gt;

&lt;p&gt;The first proposal was exactly what you’d expect today: “Let’s build a Claude Skill.”&lt;/p&gt;

&lt;p&gt;Immediately, I had a strong sense of deja vu.&lt;/p&gt;

&lt;p&gt;But my hesitation wasn’t about whether AI could do it — it was about whether it should.&lt;/p&gt;

&lt;p&gt;We were simply asking the wrong question.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Industry Shift
&lt;/h2&gt;

&lt;p&gt;A lot of engineers today feel like everything they learned over the last decade suddenly became less relevant.&lt;/p&gt;

&lt;p&gt;We are DevOps engineers.&lt;/p&gt;

&lt;p&gt;We are Platform Engineers.&lt;/p&gt;

&lt;p&gt;We used to spend time designing systems, defining standards, reviewing architectures, and planning before writing a single line of code.&lt;/p&gt;

&lt;p&gt;Every automation started with the same question:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;“How should we automate this?”&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Today, that question has quietly changed. Now we ask:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;“How can AI do this?”&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;At first glance, that sounds like progress. And sometimes it is.&lt;br&gt;
Large Language Models have fundamentally changed the way we build software. Tasks that used to take hours now take minutes, and entire prototypes appear from a single prompt.&lt;/p&gt;

&lt;p&gt;The temptation is obvious. If AI can do it… why not let AI do it?&lt;/p&gt;

&lt;p&gt;Somewhere along the way, though, many of us unconsciously changed more than just our tools. We changed our engineering process.&lt;/p&gt;

&lt;p&gt;Instead of designing the architecture first, we choose the technology first.&lt;/p&gt;

&lt;p&gt;The discussion becomes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;“Should we use Claude or GPT?”&lt;/li&gt;
&lt;li&gt;“Should this be an Agent?”&lt;/li&gt;
&lt;li&gt;“Can Hermes do it?”
Before answering a much simpler question:&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Does this problem actually need “reasoning”?&lt;/p&gt;
&lt;h2&gt;
  
  
  Runtime Reasoning vs. Deterministic Execution
&lt;/h2&gt;

&lt;p&gt;People often talk about AI Agents, Skills, MCP servers, and LLM workflows as if they’re simply the next generation of automation.&lt;/p&gt;

&lt;p&gt;They’re not.&lt;/p&gt;

&lt;p&gt;They solve a different type of problem.&lt;/p&gt;

&lt;p&gt;LLMs are great at reasoning through messy, ambiguous problems. They can adapt and change plans. But reasoning adds probability — it’s no longer 100% certain.&lt;/p&gt;

&lt;p&gt;That capability is incredibly powerful, but it comes with fundamental tradeoffs. Reasoning, by definition, introduces probability into a process.&lt;/p&gt;

&lt;p&gt;Traditional automation is deterministic. Every step is predictable, testable, and produces the same result every single time.&lt;/p&gt;

&lt;p&gt;Now let me go back to our DNS example:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;Slack Form ──► Lambda ──► DynamoDB ──► Terraform ──► Cloudflare&lt;br&gt;
&lt;/code&gt;&lt;br&gt;
Nothing in that pipeline required interpretation.&lt;/p&gt;

&lt;p&gt;We knew the state we wanted, the code already existed, and the steps were clear. So why introduce a “maybe” into a “definitely” process?&lt;/p&gt;
&lt;h2&gt;
  
  
  Reason at the Edge. Execute in the Core.
&lt;/h2&gt;

&lt;p&gt;An LLM can be incredibly valuable at the system boundaries. Imagine a developer writing in Slack: “Create a CNAME for api.staging pointing to our new load balancer.”&lt;/p&gt;

&lt;p&gt;Turning that natural language sentence into a structured JSON payload is exactly the kind of ambiguity LLMs excel at. Natural language is messy, humans omit context, and intent needs interpretation. That is a genuine reasoning problem.&lt;/p&gt;

&lt;p&gt;Structured Outputs solve formatting. They don’t solve decision-making.&lt;/p&gt;

&lt;p&gt;The risk is letting a probabilistic model decide which tools to run in production.&lt;/p&gt;

&lt;p&gt;Once user intent becomes structured data (or if you collected it via a structured Slack form in the first place), the remaining pipeline no longer benefits from reasoning. Every step after that should behave like standard production software: predictable, testable, and deterministic.&lt;/p&gt;

&lt;p&gt;Crucially, the structured data generated by the LLM passes through strict schema validation and deterministic authorization policies (RBAC) in the core. Even if an LLM is manipulated at the edge, it can only request actions — the core independently validates whether the user is authorized to perform them before execution ever touches infrastructure.&lt;/p&gt;

&lt;p&gt;The LLM proposes actions.&lt;/p&gt;

&lt;p&gt;The platform decides whether they’re allowed.&lt;/p&gt;

&lt;p&gt;That realization led us to a clear architectural principle:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Reason at the edge. Execute deterministically in the core.&lt;/strong&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  The Hidden Cost of Runtime Reasoning
&lt;/h2&gt;

&lt;p&gt;Using an AI Skill instead of a workflow seems fast at first, but it creates hidden costs for stability and security.&lt;/p&gt;

&lt;p&gt;However, when you introduce runtime reasoning into an execution path, you accept fundamental tradeoffs that directly impact production stability, security, and cost.&lt;/p&gt;

&lt;p&gt;Here is how those tradeoffs manifest in production:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Human in the Loop Bottlenecks&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If an LLM cannot be trusted to execute production changes unattended, someone must manually review every single run (When it’s open a PR for example).&lt;/p&gt;

&lt;p&gt;This brings back the very bottleneck you were trying to fix.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Probabilistic Infrastructure&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A script follows orders; an LLM makes choices.&lt;/p&gt;

&lt;p&gt;Production isn’t the place to find out the AI made a mistake.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Paying for Unused Intelligence&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Scripts cost almost nothing. LLMs cost tokens.&lt;/p&gt;

&lt;p&gt;If the workflow was already predictable, you’re paying for “intelligence” you don’t actually need.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Expanding the Attack Surface&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A deterministic workflow only executes code you explicitly wrote.&lt;/p&gt;

&lt;p&gt;An LLM executes actions based on how it interprets a prompt.&lt;/p&gt;

&lt;p&gt;The moment an LLM sits directly in the execution path, prompt injection, context poisoning, and unexpected tool execution become part of your threat model.&lt;/p&gt;

&lt;p&gt;For infrastructure, we need predictability, not improvisation.&lt;/p&gt;

&lt;p&gt;A script that does the same thing a million times is better than a system that “guesses” correctly most of the time.&lt;/p&gt;

&lt;p&gt;Use AI Where It Actually Adds Value&lt;br&gt;
We use AI more than ever — just not to run our production systems.&lt;/p&gt;

&lt;p&gt;We use AI to design Terraform modules, generate Lambda functions, build CI/CD pipelines, review Infrastructure as Code, write tests, and understand unfamiliar systems.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Let AI build automation. Don’t let AI be the automation.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Once we’ve reviewed the generated code, the reasoning phase is over.&lt;/p&gt;

&lt;p&gt;From that point onward, production executes deterministic software — not prompts. The model participates during development, not in the production control plane.&lt;/p&gt;

&lt;p&gt;The AI reasons once. The software runs forever.&lt;/p&gt;

&lt;p&gt;The 30-Second Architecture Review&lt;br&gt;
Before introducing an AI Agent, Skill, or runtime LLM into your next system, ask yourself:&lt;/p&gt;

&lt;p&gt;[ ] Does this problem actually require runtime reasoning?&lt;br&gt;
[ ] Can every execution step already be described in advance?&lt;br&gt;
[ ] Will the same input always produce the same desired output?&lt;br&gt;
[ ] Could AI generate the workflow code instead of executing it?&lt;/p&gt;

&lt;p&gt;If the steps are predictable, you don’t need an LLM. You need reliable software.&lt;/p&gt;
&lt;h2&gt;
  
  
  Final Thoughts
&lt;/h2&gt;

&lt;p&gt;LLMs are amazing at reasoning, but not every problem needs it.&lt;/p&gt;

&lt;p&gt;Use AI to understand what users want and to help you write code. But once the plan is set, let the software take over.&lt;/p&gt;

&lt;p&gt;But once your production system knows exactly what needs to happen, stop reasoning and start executing.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;User
│

▼

Natural Language
│

▼

LLM (Intent Extraction)
│

▼

Validated JSON
│

▼

Authorization (RBAC / Policies)
│

▼

Deterministic Execution

Lambda ──► DynamoDB ──► Terraform ──► Cloudflare
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Reason where things are uncertain. Execute where things are clear.&lt;/p&gt;

&lt;p&gt;Because great architecture isn’t about eliminating AI.&lt;/p&gt;

&lt;p&gt;It’s about confining uncertainty to the parts of the system that actually benefit from it.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>devops</category>
      <category>aws</category>
      <category>automation</category>
    </item>
    <item>
      <title>I Built a Language Where AI Calls Are Sandboxed by Default</title>
      <dc:creator>Harry Machura</dc:creator>
      <pubDate>Mon, 03 Aug 2026 09:48:25 +0000</pubDate>
      <link>https://dev.to/harry_machura_67442b7a2ab/i-built-a-language-where-ai-calls-are-sandboxed-by-default-3eg0</link>
      <guid>https://dev.to/harry_machura_67442b7a2ab/i-built-a-language-where-ai-calls-are-sandboxed-by-default-3eg0</guid>
      <description>&lt;h1&gt;
  
  
  I Built a Language Where AI Calls Are Sandboxed by Default
&lt;/h1&gt;

&lt;h2&gt;
  
  
  The 30-line Python problem
&lt;/h2&gt;

&lt;p&gt;Last month I needed a script that reads server logs, classifies errors with an LLM, summarizes them, and writes a report. In Python, it looked like this:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Import the SDK&lt;/li&gt;
&lt;li&gt;Initialize the client&lt;/li&gt;
&lt;li&gt;Handle the API response&lt;/li&gt;
&lt;li&gt;Parse JSON&lt;/li&gt;
&lt;li&gt;Add &lt;code&gt;asyncio.gather()&lt;/code&gt; because sequential calls took 8 seconds&lt;/li&gt;
&lt;li&gt;Write a custom sandbox because I don't trust LLMs with &lt;code&gt;exec&lt;/code&gt; and file writes&lt;/li&gt;
&lt;li&gt;Package it in Docker because &lt;code&gt;requirements.txt&lt;/code&gt; always breaks on the server&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;80 lines later&lt;/strong&gt;, it worked. But it felt wrong. I wasn't building logic — I was plumbing.&lt;/p&gt;

&lt;p&gt;So I asked myself: &lt;em&gt;What if AI operations were language primitives, not library calls?&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Meet Pipe
&lt;/h2&gt;

&lt;p&gt;Pipe is a small runtime (~10 MB, single binary, zero dependencies) that treats &lt;code&gt;summarize&lt;/code&gt;, &lt;code&gt;translate&lt;/code&gt;, &lt;code&gt;classify&lt;/code&gt;, and &lt;code&gt;ask&lt;/code&gt; as first-class citizens — on the same level as &lt;code&gt;+&lt;/code&gt;, &lt;code&gt;sort&lt;/code&gt;, or &lt;code&gt;len&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Try it&lt;/p&gt;

&lt;p&gt;Browser Playground (WASM, no install): pipe-lang.com&lt;/p&gt;

&lt;p&gt;Source: github.com/MachuraHarry/pipe&lt;/p&gt;

&lt;p&gt;Docs: pipe-lang.com/docs&lt;/p&gt;

</description>
      <category>ai</category>
      <category>opensource</category>
      <category>programming</category>
      <category>go</category>
    </item>
    <item>
      <title>Can revenge be heroic?</title>
      <dc:creator>Brian Kim</dc:creator>
      <pubDate>Mon, 03 Aug 2026 09:42:20 +0000</pubDate>
      <link>https://dev.to/bibimbop123/can-revenge-be-heroic-4acd</link>
      <guid>https://dev.to/bibimbop123/can-revenge-be-heroic-4acd</guid>
      <description>&lt;h1&gt;
  
  
  Brian Kim
&lt;/h1&gt;

&lt;p&gt;&lt;strong&gt;1st Period&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
&lt;strong&gt;Mrs. Lukens&lt;/strong&gt;  &lt;/p&gt;

&lt;h1&gt;
  
  
  Can revenge be heroic?
&lt;/h1&gt;

&lt;p&gt;Hamlet proves that revenge is heroic, despite it’s evil nature. Through the course of the book, Hamlet restores peace to his kingdom and balances the offenses of King Claudius. Still, Heroism is a matter of perspective and revenge is the action of one’s own justice onto another. What makes revenge heroic is the intent of the one who desires justice, not the aftermath. Hamlet demonstrates his heroism as his revenge begins as an attempt to preserve his fathers honor. His revenge begins heroic, however is later easily corrupted. It is learned, revenge is a never ending cycle and the true heros emerge to be the ones that forgive.&lt;/p&gt;

&lt;p&gt;Hamlet’s revenge was justified and therefore heroic. Nevertheless, the changes in him become evident and eventually detract from his righteousness. Hamlet appears to be crazy, although it is said to be merely an act to fool the king, even the reader is unable to distinguish his lunacy as Hamlet was truly blinded by revenge. The first sign of corruption is shown as Hamlet denies his love for Ophelia. Here it is seen, his desire to avenge his father overshadows his love for others. “Get thee to a nunnery. Why wouldst thou be a breeder of sinners?...”(act 3,sc.1), his revenge on Claudius manifests on his conversation with Ophelia. Revenge loses it’s heroism when it changes to hatred. Nevertheless, Hamlets retains his true feelings as he’d rather see Ophelia alone than with another man. Another corruption to heroism in revenge is shown when Hamlet kills Polonius. Hamlet’s revenge does not stop with murder; he now feels nothing and is willing to do whatever it takes to have his revenge. “A bloody deed - almost as bad, good mother, as kill a king and marry his brother”(act 3, sc.4), Hamlet’s revenge has turned evil, yet still portrayed as a justified, almost heroic evil.&lt;/p&gt;

&lt;p&gt;With all evils, revenge becomes a never ending cycle. This is shown, following the death of Polonius, in Ophelia. Hamlet’s revenge has spread amongst others including Ophelia and Laertes. Ophelia, for one, is driven to madness and eventually suicide. This shows that revenge is a lose lose situation for both the revenger and the revengee. The cycle continues as Laertes then is manipulated by Claudius and becomes the downfall of Hamlet’s life. Laertes, much like Hamlet, was consumed by his desire to get revenge. Fittingly, Laerte’s plot to kill Hamlet becomes his own death. Still Laertes forgives Hamlet for the murder of his father, “The king, the king’s to blame… he’s justly served. It is poison tempered by himself. Exchange forgiveness with me, noble Hamlet.” Here it is shown again, the backlash of revenge in Claudius as well as the cease of evil through forgiveness. Laertes becomes the true hero as he foils Claudius’s plot and returns honor to Hamlet’s family. Forgiveness emerges to be the best revenge. Forgiveness is pure revenge without corruption..&lt;/p&gt;

&lt;p&gt;Moreover, all was not lost in Hamlet’s revenge, Hamlet’s plot appears to be successful. The king has been overthrown and order has returned to the kingdom of Denmark. Hamlet is seen as hero through the way he is buried, “Bear Hamlet like a soldier to the stage...the soldier’s music and the rite of war speak loudly for him.”(act 5, sc.2) Like a martyr, Hamlet does not die in vain. He fulfils his father’s wishes and restores honor and justice in the kingdom. Yet, was it worth it? Hamlet dies pitifully, witnessing the death of his father, mother, and lover.&lt;/p&gt;

&lt;p&gt;There is no doubt that revenge is heroic. To enact revenge is to stand up for what one believes in. Still, Hamlet best displays the setbacks of revenge as he dies a pitiful death. Through the course of the book, revenge is revealed to be easily corrupted and shown as a never ending cyle. Forgiveness, on the other hand, is shown as a pure revenge that is not corrupted and finite. Perhaps if Hamlet forgave Claudius, the truth of Claudius’s evil deeds would come back to haunt him. In any case, how we carry on our revenge determines what kind of hero we choose to be.&lt;/p&gt;

</description>
      <category>education</category>
      <category>writing</category>
    </item>
    <item>
      <title>We crossed 6,000 downloads. Here's what we shipped to get there.</title>
      <dc:creator>Srinivas Kondepudi</dc:creator>
      <pubDate>Mon, 03 Aug 2026 09:39:55 +0000</pubDate>
      <link>https://dev.to/sirinivask/we-crossed-6000-downloads-heres-what-we-shipped-to-get-there-3a1j</link>
      <guid>https://dev.to/sirinivask/we-crossed-6000-downloads-heres-what-we-shipped-to-get-there-3a1j</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fap2blzp2unap9fz5gmd9.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fap2blzp2unap9fz5gmd9.png" alt="Chron" width="800" height="448"&gt;&lt;/a&gt;Tuesday morning. Your SOC 2 auditor emails you.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Can you provide evidence of human review for all AI-assisted code changes in the last 90 days — which files were modified, what prompts were used, and whether any credentials were visible in context?"&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;You open your IDE. Git log? Commits are there. PR history? Reviews too. But the AI session itself — the conversation, the code it proposed, whether it saw your &lt;code&gt;.env&lt;/code&gt; file, which compliance controls it touched — gone.&lt;/p&gt;

&lt;p&gt;That gap is why I built Chron.&lt;/p&gt;




&lt;h2&gt;
  
  
  What Chron is
&lt;/h2&gt;

&lt;p&gt;Chron is an MCP server that runs alongside your AI coding tool. Every message, every code change, every detected secret — locally timestamped, hash-chained, and stored in a SQLite database you own. No cloud. No data sharing. Works offline.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Install once&lt;/span&gt;
npm &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-g&lt;/span&gt; chron-mcp

&lt;span class="c"&gt;# Check setup&lt;/span&gt;
chron doctor
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Works with Claude Code, Cursor, Windsurf, Continue.dev — any MCP-compatible tool.&lt;/p&gt;




&lt;h2&gt;
  
  
  The last four releases: answers to questions auditors actually ask
&lt;/h2&gt;

&lt;h3&gt;
  
  
  v0.1.39 — "Which sessions are worth reviewing first?"
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;chron risk &lt;span class="nt"&gt;--since&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;30d

SESSION           SCORE  BAND      SIGNALS
a1b2c3d4          87     critical  secrets·auth·infra
e5f6g7h8          52     high      auth·findings&lt;span class="o"&gt;(&lt;/span&gt;2&lt;span class="o"&gt;)&lt;/span&gt;
i9j0k1l2          28     review    code_changes
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The attention score: deterministic 0–100 per session. No ML, no API calls. Pure signal from what actually happened: secrets detected (+25), auth code changed (+15), infra modified (+12), open compliance findings (+8 each). A security lead can triage 90 days of AI sessions in under a minute.&lt;/p&gt;




&lt;h3&gt;
  
  
  v0.1.40 — "Can I get a one-pager for this audit?"
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;chron dashboard &lt;span class="nt"&gt;--since&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;30d &lt;span class="nt"&gt;--output&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;q3-audit.html

✓ Written: q3-audit.html
  8 sessions · 4 open findings · 1 critical · 2 high
  Coverage: 6 controls covered · 3 needs evidence
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Five sections in a single static HTML file — no server, no login, no port: executive summary, sessions ranked by risk score, findings grouped by framework (SOC 2 / ISO 27001 / EU AI Act / NIST AI RMF), a control coverage map, and contextual next actions. Open in a browser. Print to PDF. Attach to the audit package.&lt;/p&gt;




&lt;h3&gt;
  
  
  v0.1.41 — "Walk me through this specific session."
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;chron dashboard &lt;span class="nt"&gt;--session&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;a1b2c3d4

✓ Written: chron-session-a1b2c3d.html
  Score: 87/100 &lt;span class="o"&gt;(&lt;/span&gt;critical&lt;span class="o"&gt;)&lt;/span&gt; · 3 findings · tamper: ✓ ok
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The session detail report: attention score breakdown, full timeline with code diffs, secrets with masked values, compliance finding cards with pre-built accept/dismiss CLI commands, which controls the session touches, and a tamper evidence bar (hash chain + NTP clock + Ed25519 signature).&lt;/p&gt;

&lt;p&gt;An auditor can open one file and understand what happened, why it matters, which policies it touched, and what action remains — without accessing any internal system.&lt;/p&gt;




&lt;h3&gt;
  
  
  v0.1.42 — "Is this a one-off, or a pattern?"
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;chron patterns &lt;span class="nt"&gt;--since&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;30d
&lt;span class="go"&gt;
Chron Patterns  last 30d · 8 sessions

●●●● HIGH    Repeated auth/access-control code modified
             4 sessions touched these paths
             · auth, login, rbac, permission…
             Sessions: a1b2c3d4  e5f6g7h8  +2 more

●●●○ MEDIUM  Recurring SOC 2 finding unresolved
             soc2.cc6_1.ai_access_control_change in 2 sessions

●●●○ MEDIUM  Findings unresolved for 28+ days
             3 open findings across 2 sessions

4 patterns detected  2 high  2 medium
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Six pattern types: repeated secret exposure, repeated code-signal category changes (auth, infra, AI governance, monitoring), recurring unresolved findings, high-attention recurring sessions, stale findings (configurable: &lt;code&gt;--stale=21&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;&lt;code&gt;--json&lt;/code&gt; outputs &lt;code&gt;{ patterns, session_count }&lt;/code&gt; — already shaped for SIEM ingestion in the next release. Pattern IDs are stable keys (&lt;code&gt;repeated_auth_access_control_code_modified&lt;/code&gt;, &lt;code&gt;recurring_finding:soc2.cc6_1.*&lt;/code&gt;) so SIEM rules can match without parsing titles.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One-off findings are noise. Patterns are risk.&lt;/strong&gt; This command tells you which is which.&lt;/p&gt;




&lt;h2&gt;
  
  
  The number
&lt;/h2&gt;

&lt;p&gt;We crossed &lt;strong&gt;6,000 downloads&lt;/strong&gt; this week. As of publishing: &lt;strong&gt;6,139&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;No fundraise. No acquisition. No VC backing. Just a CLI that answers a question nobody had an answer for, installed by 6,000+ developers who needed an audit trail for their AI coding sessions.&lt;/p&gt;




&lt;h2&gt;
  
  
  What's next
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;v0.1.43 — SIEM Risk Events:&lt;/strong&gt; emit &lt;code&gt;pattern_detected&lt;/code&gt;, &lt;code&gt;high_attention_session&lt;/code&gt;, &lt;code&gt;attention_score_computed&lt;/code&gt; events into your pipeline&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;v0.1.44 — Evidence Connectors:&lt;/strong&gt; &lt;code&gt;chron evidence import&lt;/code&gt; to link policy documents to coverage gaps&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Phase 3.5 — Policy Evidence Registry:&lt;/strong&gt; map your policies to the controls Chron already tracks&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-g&lt;/span&gt; chron-mcp
chron doctor

&lt;span class="c"&gt;# After a few AI sessions:&lt;/span&gt;
chron risk
chron patterns &lt;span class="nt"&gt;--since&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;30d
chron dashboard &lt;span class="nt"&gt;--output&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;report.html
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;GitHub: &lt;a href="https://github.com/sirinivask/chron" rel="noopener noreferrer"&gt;https://github.com/sirinivask/chron&lt;/a&gt;&lt;/p&gt;

</description>
      <category>devops</category>
      <category>security</category>
      <category>typescript</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Compressing Video to a Target File Size: The Bitrate Math in TypeScript</title>
      <dc:creator>Noah Chen</dc:creator>
      <pubDate>Mon, 03 Aug 2026 09:39:39 +0000</pubDate>
      <link>https://dev.to/noahchenbuilds/compressing-video-to-a-target-file-size-the-bitrate-math-in-typescript-4m00</link>
      <guid>https://dev.to/noahchenbuilds/compressing-video-to-a-target-file-size-the-bitrate-math-in-typescript-4m00</guid>
      <description>&lt;p&gt;&lt;em&gt;A practical calculator for turning an upload limit into a video bitrate, with enough margin for audio and container overhead.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;“Make this video smaller” is an open-ended request. “Make this three-minute video fit under 10 MB” is an engineering constraint.&lt;/p&gt;

&lt;p&gt;The second version sounds more precise, but a quality slider alone cannot solve it. A quality setting tells an encoder how aggressively to preserve detail. It does not directly tell us how many bytes the final file may contain. If the destination has a hard upload limit, the useful starting point is a bit budget.&lt;/p&gt;

&lt;p&gt;This article builds that calculation in TypeScript, then looks at the assumptions that make the answer less exact than the formula first appears.&lt;/p&gt;

&lt;h2&gt;
  
  
  File Size Is Bitrate Multiplied by Time
&lt;/h2&gt;

&lt;p&gt;A video file contains several streams plus a container. For a simple MP4, the largest pieces are usually:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the video stream;&lt;/li&gt;
&lt;li&gt;the audio stream;&lt;/li&gt;
&lt;li&gt;container metadata and indexing overhead.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If we ignore overhead for a moment, the relationship is straightforward:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;file size in bits = total bitrate in bits per second × duration in seconds
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Rearranging it gives us the total bitrate available for a target size:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;total bitrate = target size in bits / duration in seconds
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That total must cover both video and audio. The approximate video budget is therefore:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;video bitrate = total bitrate - audio bitrate - overhead allowance
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The result is not a promise. It is a budget that an encoder can aim at.&lt;/p&gt;

&lt;h2&gt;
  
  
  Be Explicit About MB and MiB
&lt;/h2&gt;

&lt;p&gt;Before writing code, decide what “10 MB” means.&lt;/p&gt;

&lt;p&gt;Storage vendors and many web services use decimal megabytes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1 MB = 1,000,000 bytes
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Operating systems and developer tools often display binary mebibytes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1 MiB = 1,048,576 bytes
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The difference is about 4.9%. That is large enough to turn a file that looks safe locally into a rejected upload. For a hard external limit, I prefer to calculate with decimal MB and keep an additional safety margin. For an internal tool where the unit is clearly MiB, I make that choice explicit in the function name or input type.&lt;/p&gt;

&lt;h2&gt;
  
  
  A Small TypeScript Calculator
&lt;/h2&gt;

&lt;p&gt;The function below accepts a decimal target size, duration, audio bitrate, container allowance, and safety margin. It returns the video bitrate to pass to an encoder.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;BitrateBudgetInput&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;targetMB&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;durationSeconds&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;audioKbps&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;containerOverheadFraction&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;safetyMarginFraction&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;BitrateBudget&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;totalKbps&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;usableKbps&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;audioKbps&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;videoKbps&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;calculateVideoBitrate&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="nx"&gt;targetMB&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;durationSeconds&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;audioKbps&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;96&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;containerOverheadFraction&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.02&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;safetyMarginFraction&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.03&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}:&lt;/span&gt; &lt;span class="nx"&gt;BitrateBudgetInput&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;BitrateBudget&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nb"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isFinite&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;targetMB&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;targetMB&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;RangeError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;targetMB must be greater than zero&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nb"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isFinite&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;durationSeconds&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;durationSeconds&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;RangeError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;durationSeconds must be greater than zero&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;targetBits&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;targetMB&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="nx"&gt;_000_000&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;totalKbps&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;targetBits&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;durationSeconds&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;reservedFraction&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
    &lt;span class="nx"&gt;containerOverheadFraction&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;safetyMarginFraction&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;reservedFraction&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;reservedFraction&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;RangeError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;reserved fractions must total less than one&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;usableKbps&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;totalKbps&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;reservedFraction&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;videoKbps&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;floor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;usableKbps&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;audioKbps&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;videoKbps&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;RangeError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;The target is too small for this duration and audio bitrate&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;totalKbps&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;floor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;totalKbps&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="na"&gt;usableKbps&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;floor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;usableKbps&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="nx"&gt;audioKbps&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;videoKbps&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For a three-minute video with a 10 MB limit:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;budget&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;calculateVideoBitrate&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;targetMB&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;durationSeconds&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;180&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;audioKbps&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;96&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;budget&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The raw total is roughly 444 kbps. After reserving 5% for overhead and safety, then allocating 96 kbps to audio, the video stream receives about 326 kbps.&lt;/p&gt;

&lt;p&gt;That is a tight budget. It may be acceptable for a mostly static 720p screen recording. It will probably look rough for fast camera movement at 1080p. The calculator can tell us whether the numbers fit. It cannot decide whether the visual result is useful.&lt;/p&gt;

&lt;h2&gt;
  
  
  Add Tests Around the Boundary Conditions
&lt;/h2&gt;

&lt;p&gt;The arithmetic is simple enough that the most valuable tests are about invalid or unrealistic inputs.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;describe&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;it&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;vitest&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;calculateVideoBitrate&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;./bitrate-budget&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nf"&gt;describe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;calculateVideoBitrate&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;returns a positive video budget&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;calculateVideoBitrate&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;targetMB&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;25&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;durationSeconds&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;120&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;audioKbps&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;96&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;

    &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;videoKbps&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toBeGreaterThan&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;videoKbps&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toBeLessThan&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;totalKbps&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;rejects a zero duration&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
      &lt;span class="nf"&gt;calculateVideoBitrate&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;targetMB&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;durationSeconds&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
    &lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toThrow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;RangeError&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;rejects an impossible audio allocation&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
      &lt;span class="nf"&gt;calculateVideoBitrate&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
        &lt;span class="na"&gt;targetMB&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;durationSeconds&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;600&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;audioKbps&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;128&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;}),&lt;/span&gt;
    &lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toThrow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;RangeError&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An impossible result is useful information. It means at least one constraint must change: shorten the video, increase the file limit, lower the audio bitrate, remove audio, reduce resolution, or accept visibly lower quality.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Two-Pass Encoding Gets Closer
&lt;/h2&gt;

&lt;p&gt;A constant bitrate is easy to reason about, but real video does not have constant complexity. A static product screen needs fewer bits than a transition, scrolling page, or camera pan.&lt;/p&gt;

&lt;p&gt;Two-pass encoding uses the first pass to analyze where complexity occurs. The second pass spends more of the fixed budget on difficult sections and less on easy ones. It is therefore a sensible choice when file size matters more than encoding speed.&lt;/p&gt;

&lt;p&gt;A simplified FFmpeg flow looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ffmpeg &lt;span class="nt"&gt;-y&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; input.mp4 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-c&lt;/span&gt;:v libx264 &lt;span class="nt"&gt;-b&lt;/span&gt;:v 326k &lt;span class="nt"&gt;-pass&lt;/span&gt; 1 &lt;span class="nt"&gt;-an&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-f&lt;/span&gt; mp4 /dev/null

ffmpeg &lt;span class="nt"&gt;-i&lt;/span&gt; input.mp4 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-c&lt;/span&gt;:v libx264 &lt;span class="nt"&gt;-b&lt;/span&gt;:v 326k &lt;span class="nt"&gt;-pass&lt;/span&gt; 2 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-c&lt;/span&gt;:a aac &lt;span class="nt"&gt;-b&lt;/span&gt;:a 96k output.mp4
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On Windows, the first command uses &lt;code&gt;NUL&lt;/code&gt; instead of &lt;code&gt;/dev/null&lt;/code&gt;. A production wrapper should also remove the pass-log files and handle failed processes.&lt;/p&gt;

&lt;p&gt;Even two-pass encoding can miss an exact target because of muxing overhead, encoder behavior, subtitles, metadata, and rounding. That is why the calculator keeps a safety margin instead of aiming at the final byte.&lt;/p&gt;

&lt;h2&gt;
  
  
  Codec Choice Changes the Quality, Not the Budget
&lt;/h2&gt;

&lt;p&gt;If the target size and duration are fixed, the total bitrate is fixed too. Switching from H.264 to H.265 does not create a larger bit budget. It tries to produce better visual quality with the same budget.&lt;/p&gt;

&lt;p&gt;That tradeoff includes compatibility and encoding cost:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;H.264 is widely supported and usually encodes faster.&lt;/li&gt;
&lt;li&gt;H.265 can preserve more detail at low bitrates, especially at higher resolutions.&lt;/li&gt;
&lt;li&gt;Older browsers, devices, or editing workflows may not handle H.265 as predictably.&lt;/li&gt;
&lt;li&gt;Re-encoding an already compressed file can introduce additional artifacts regardless of codec.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For a file that must play everywhere, H.264 may be the safer result. For a controlled playback environment and a severe size limit, H.265 may be worth testing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Resolution Is Often the Most Honest Lever
&lt;/h2&gt;

&lt;p&gt;At some point, there are not enough bits to describe every pixel well.&lt;/p&gt;

&lt;p&gt;Trying to keep 4K resolution at a few hundred kilobits per second usually produces a file that is technically 4K but visually worse than a clean 720p version. Downscaling reduces the number of pixels the encoder must describe and can improve perceived quality at the same file size.&lt;/p&gt;

&lt;p&gt;For product videos, I use a simple decision order:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Remove dead time and unnecessary scenes.&lt;/li&gt;
&lt;li&gt;Keep only the audio quality the content needs.&lt;/li&gt;
&lt;li&gt;Calculate the available video bitrate.&lt;/li&gt;
&lt;li&gt;Choose a resolution appropriate for that bitrate and viewing context.&lt;/li&gt;
&lt;li&gt;Encode with a safety margin.&lt;/li&gt;
&lt;li&gt;Inspect text, cursor movement, transitions, and other high-risk frames.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The final inspection matters. A passing file-size check does not mean small interface text remains readable.&lt;/p&gt;

&lt;h2&gt;
  
  
  When Not to Build the Pipeline
&lt;/h2&gt;

&lt;p&gt;The TypeScript function is useful when compression is part of a repeatable system: an upload service, media queue, desktop tool, or CI job. In that situation, controlling the encoder and recording the exact settings are worth the engineering effort.&lt;/p&gt;

&lt;p&gt;For an occasional file, maintaining that pipeline may cost more time than it saves. A browser-based tool such as &lt;a href="https://videocompress.ai/" rel="noopener noreferrer"&gt;VideoCompress&lt;/a&gt; exposes target-size and advanced controls without requiring a local FFmpeg workflow. The tradeoff is that processing happens in the cloud, so upload time and data sensitivity matter. Its free account also uses monthly credits rather than offering unlimited processing.&lt;/p&gt;

&lt;p&gt;That distinction is the practical one: automate recurring workloads; use a focused interface for one-off work; keep sensitive media local.&lt;/p&gt;

&lt;h2&gt;
  
  
  Treat the Formula as a Constraint, Not a Quality Score
&lt;/h2&gt;

&lt;p&gt;Target-size compression becomes easier to reason about once the upload limit is translated into a bit budget. The arithmetic tells us what is possible. The encoder decides how to distribute those bits. Human inspection determines whether the result is acceptable.&lt;/p&gt;

&lt;p&gt;Those are three separate jobs.&lt;/p&gt;

&lt;p&gt;A small calculator prevents impossible settings from reaching the encoder and makes tradeoffs visible before a long job starts. It also replaces the vague instruction to “compress it more” with better questions: Can we shorten it? Can we reduce resolution? Is the audio budget too high? Does compatibility require H.264? Is this recurring enough to automate?&lt;/p&gt;

&lt;p&gt;That is usually where a reliable video workflow begins.&lt;/p&gt;

</description>
      <category>typescript</category>
      <category>webdev</category>
      <category>performance</category>
      <category>softwareengineering</category>
    </item>
    <item>
      <title>A PDF a Human Reads and a Machine Parses at the Same Time: How PDF4me Builds ZUGFeRD E-Invoices</title>
      <dc:creator>PDF4me</dc:creator>
      <pubDate>Mon, 03 Aug 2026 09:37:16 +0000</pubDate>
      <link>https://dev.to/pdf4me/a-pdf-a-human-reads-and-a-machine-parses-at-the-same-time-how-pdf4me-builds-zugferd-e-invoices-5enf</link>
      <guid>https://dev.to/pdf4me/a-pdf-a-human-reads-and-a-machine-parses-at-the-same-time-how-pdf4me-builds-zugferd-e-invoices-5enf</guid>
      <description>&lt;p&gt;Picture the scenario: your invoicing pipeline generates a clean, branded PDF for a German B2B customer. It looks right. It would print fine, email fine, and satisfy anyone who opens it by hand. Then it bounces, because since January 1, 2025, that customer is legally required to receive invoices in a format their software can parse without a human retyping the totals. A pretty PDF isn't enough anymore, and honestly, for a machine, it never really was the point.&lt;/p&gt;

&lt;p&gt;The part that surprises people who haven't dealt with this yet: the mandate doesn't force you to give up the human-readable PDF. It just requires that PDF to carry a second, structured version of itself, riding along inside it. That format is called ZUGFeRD, with an internationally aligned sibling called Factur-X.&lt;/p&gt;

&lt;p&gt;If you've never had to build one, it's worth understanding the mechanics before the code, because it's a genuinely clever piece of engineering, not just a compliance checkbox. So how does a single file manage to be both a human-readable invoice and a machine-parseable one at once?&lt;/p&gt;

&lt;h2&gt;
  
  
  What a ZUGFeRD invoice actually is
&lt;/h2&gt;

&lt;p&gt;Open a ZUGFeRD invoice in Adobe Acrobat or any PDF viewer and you see a normal invoice: logo, line items, totals, payment terms, nothing unusual. But embedded inside that same file, in its attachments, sits an XML document carrying the exact same invoice data in structured, typed form: invoice number, line items, tax rates, totals, every field an accounting system needs, tagged rather than buried in a paragraph a parser has to guess at.&lt;/p&gt;

&lt;p&gt;The container format making this possible is &lt;a href="https://docs.pdf4me.com/pdf4me-api/convert/create-pdfa/" rel="noopener noreferrer"&gt;PDF/A-3&lt;/a&gt;, the only PDF/A variant that permits arbitrary file attachments while still meeting the archival standard's long-term readability requirements. PDF/A-1 and PDF/A-2 explicitly forbid embedded attachments; PDF/A-3 was built for exactly this use case, which is why every ZUGFeRD file you'll open is, underneath, a PDF/A-3b document with an XML file riding inside it.&lt;/p&gt;

&lt;p&gt;The embedded XML follows EN 16931, the EU's semantic data model for electronic invoices, with Germany's own XRechnung profile layered on top for domestic traffic. ZUGFeRD has shipped several versions (the 1.0 line through the current 2.x releases), each defining conformance levels, typically BASIC, COMFORT, EXTENDED, and the EN16931-aligned and XRECHNUNG profiles, trading structural strictness for how much invoice detail gets exposed to the machine-readable layer.&lt;/p&gt;

&lt;p&gt;This matters most directly for two kinds of teams: anyone generating outbound invoices for German business customers, and anyone receiving invoices who needs to parse the embedded XML straight into an ERP or accounting system instead of manually retyping totals off a PDF.&lt;/p&gt;

&lt;h2&gt;
  
  
  The two REST primitives underneath it
&lt;/h2&gt;

&lt;p&gt;Strip the standard's name away and ZUGFeRD generation is two REST calls chained together. First, &lt;a href="https://docs.pdf4me.com/pdf4me-api/convert/create-pdfa/" rel="noopener noreferrer"&gt;Create PDF/A&lt;/a&gt; converts the visible invoice into PDF/A-3b, one of eight conformance levels the endpoint supports (PDF/A-1b, PDF/A-1a, PDF/A-2b, PDF/A-2u, PDF/A-2a, PDF/A-3b, PDF/A-3u, PDF/A-3a). Second, &lt;a href="https://docs.pdf4me.com/pdf4me-api/edit/add-attachment-to-pdf/" rel="noopener noreferrer"&gt;Add Attachment to PDF&lt;/a&gt; embeds the invoice XML inside that PDF/A-3 shell as a file attachment, the same mechanism you'd use to attach a spreadsheet to a report, just pointed at an XML payload instead. The endpoint's own schema makes this concrete: alongside the top-level &lt;code&gt;docContent&lt;/code&gt; and &lt;code&gt;docName&lt;/code&gt; for the base PDF, an &lt;code&gt;attachments&lt;/code&gt; array holds one object per file to embed, each carrying its own &lt;code&gt;docName&lt;/code&gt; (the attachment's filename as it appears inside the PDF, typically &lt;code&gt;invoice-data.xml&lt;/code&gt;) and &lt;code&gt;docContent&lt;/code&gt; (that file's own Base64 content).&lt;/p&gt;

&lt;p&gt;Here's what that looks like live-verified against &lt;code&gt;docs.pdf4me.com&lt;/code&gt;, chained end to end:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;base64&lt;/span&gt;

&lt;span class="n"&gt;API_KEY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;YOUR_API_KEY&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;  &lt;span class="c1"&gt;# Base64-encoded, per PDF4me's Basic auth convention
&lt;/span&gt;&lt;span class="n"&gt;headers&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Authorization&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Basic &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;API_KEY&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Content-Type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;application/json&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;# Step 1: convert the invoice PDF to PDF/A-3b
&lt;/span&gt;&lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;invoice.pdf&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;rb&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;pdf_b64&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;base64&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;b64encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read&lt;/span&gt;&lt;span class="p"&gt;()).&lt;/span&gt;&lt;span class="nf"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="n"&gt;pdfa_payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;docContent&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;pdf_b64&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;docName&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;invoice&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;compliance&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;PdfA3b&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;allowUpgrade&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;allowDowngrade&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="n"&gt;pdfa_resp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://api.pdf4me.com/api/v2/PdfA&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;pdfa_payload&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;headers&lt;/span&gt;
&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="c1"&gt;# Step 2: embed the invoice XML inside the PDF/A-3b shell
&lt;/span&gt;&lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;invoice-data.xml&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;rb&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;xml_b64&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;base64&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;b64encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read&lt;/span&gt;&lt;span class="p"&gt;()).&lt;/span&gt;&lt;span class="nf"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="n"&gt;attach_payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;docContent&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;pdfa_resp&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;docContent&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;docName&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;invoice.pdf&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;attachments&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;docName&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;invoice-data.xml&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;docContent&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;xml_b64&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="n"&gt;zugferd_resp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://api.pdf4me.com/api/v2/AddAttachmentToPdf&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;attach_payload&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;headers&lt;/span&gt;
&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="c1"&gt;# zugferd_resp["docContent"] is now the finished ZUGFeRD PDF/A-3b file, Base64-encoded
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One honest flag: PDF4me's own Create PDF/A docs page renders its response example inconsistently with every other endpoint here, so double check the exact response key your account actually returns before wiring &lt;code&gt;pdfa_resp["docContent"]&lt;/code&gt; into production. Every other call in this chain uses consistent &lt;code&gt;docContent&lt;/code&gt;/&lt;code&gt;docName&lt;/code&gt; naming, and it's a safe bet Create PDF/A does too, but verify against a real response rather than trusting a docs page's formatting on this one.&lt;/p&gt;

&lt;p&gt;Going the other direction, &lt;a href="https://docs.pdf4me.com/pdf4me-api/extract/extract-attachment-from-pdf/" rel="noopener noreferrer"&gt;Extract Attachment from PDF&lt;/a&gt; does the reverse: hand it a ZUGFeRD PDF, get back the embedded XML as structured data, live-verified as an &lt;code&gt;outputDocuments&lt;/code&gt; array of &lt;code&gt;{fileName, streamFile}&lt;/code&gt; objects, one per embedded file, exactly what a receiving ERP system needs to actually consume the invoice instead of just archiving it unread.&lt;/p&gt;

&lt;p&gt;Knowing this matters even if you never touch the REST API directly. It explains what PDF4me's no-code action is doing under the hood, and it tells you where to look if a generated file doesn't validate the way you expect.&lt;/p&gt;

&lt;p&gt;Which approach to reach for depends on how much control the job needs. Calling Create PDF/A and Add Attachment to PDF yourself makes sense if you're already generating the invoice PDF through your own template engine and just need the final embedding step, or if you want to validate the XML against your own schema before it goes anywhere near the PDF. Reaching for Create ZUGFeRD Invoice directly makes more sense the moment you're already living inside Make, Zapier, Power Automate, or n8n for the rest of the invoicing flow, since it collapses both REST calls, plus the conformance-level bookkeeping, into a single configured step you're not maintaining yourself.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one-action version
&lt;/h2&gt;

&lt;p&gt;Chaining two REST calls yourself is a reasonable way to build this, but PDF4me also ships it as a single purpose-built action, Create ZUGFeRD Invoice, across all four automation platforms this cluster covers. Feed it invoice data as XML, JSON, or CSV, pick a conformance level, and get back a hybrid PDF/A-3 file with the XML embedded and aligned to EN 16931. No manual REST chaining required.&lt;/p&gt;

&lt;p&gt;In &lt;a href="https://docs.pdf4me.com/integration/make/pdf/create-zugferd-invoice/" rel="noopener noreferrer"&gt;Make&lt;/a&gt;, the action accepts invoice data in any of those three formats and returns the ready-to-send hybrid file. A documented &lt;a href="https://docs.pdf4me.com/blog/create-zugferd-invoice-make-dropbox-workflow/" rel="noopener noreferrer"&gt;four-module Dropbox workflow&lt;/a&gt; walks through the whole thing: pull a base PDF and a ZUGFeRD 2.0+ XML file from Dropbox, run them through Create ZUGFeRD Invoice at EN16931 conformance, push the finished hybrid invoice back to Dropbox.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://docs.pdf4me.com/integration/zapier/pdf/create-zugferd-invoice/" rel="noopener noreferrer"&gt;Zapier's&lt;/a&gt; version supports ZUGFeRD 1.0 and the full 2.x line, across BASIC, COMFORT, EXTENDED, EN16931, and XRECHNUNG conformance. Its &lt;a href="https://docs.pdf4me.com/blog/zugferd-invoice-zapier-dropbox-xml-workflow/" rel="noopener noreferrer"&gt;Dropbox + XML walkthrough&lt;/a&gt; triggers on a new file landing in Dropbox, fetches the base PDF and XML payload, runs Create ZUGFeRD Invoice with XmlWithPdf output, and saves the result back to Dropbox at BASIC conformance in the documented example.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://docs.pdf4me.com/integration/power-automate/pdf/create-zugferd-invoice/" rel="noopener noreferrer"&gt;Power Automate's&lt;/a&gt; action is built explicitly around the German B2B mandate, and pulls source data from SharePoint, OneDrive, Outlook, or Dataverse in addition to XML, JSON, or CSV. Two documented walkthroughs cover the two source-format paths you're most likely to hit: a &lt;a href="https://docs.pdf4me.com/blog/zugferd-invoice-power-automate-dropbox-json-workflow/" rel="noopener noreferrer"&gt;Dropbox + JSON flow&lt;/a&gt; for teams generating invoice data programmatically, and a &lt;a href="https://docs.pdf4me.com/blog/zugferd-invoice-power-automate-dropbox-xml-workflow/" rel="noopener noreferrer"&gt;Dropbox + XML flow&lt;/a&gt; for teams that already have ZUGFeRD-formatted XML sitting in a system somewhere and just need it embedded.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://docs.pdf4me.com/integration/n8n/pdf/create-zugferd-invoice-latest/" rel="noopener noreferrer"&gt;n8n's&lt;/a&gt; action generates ZUGFeRD 2.0 through 2.4, with EN16931, XRECHNUNG, BASIC, and EXTENDED conformance, from XML, JSON, or CSV. Its &lt;a href="https://docs.pdf4me.com/blog/zugferd-invoice-n8n-dropbox-json-workflow/" rel="noopener noreferrer"&gt;Dropbox + JSON walkthrough&lt;/a&gt; uploads invoice JSON, runs it through the ZUGFeRD action, and lands the finished file back in Dropbox, with real screenshots of every mapping.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this doesn't do for you
&lt;/h2&gt;

&lt;p&gt;A structurally valid ZUGFeRD file isn't automatically an invoice your counterparty's accounts-payable system will accept without complaint. Conformance levels exist precisely because different trading partners and national profiles expect different amounts of structured detail, and whether your invoice data actually satisfies EN 16931's business rules is a data-quality question the generation step alone doesn't answer for you. PDF4me's &lt;a href="https://docs.pdf4me.com/integration/make/pdf/validate-pdfa/" rel="noopener noreferrer"&gt;Validate PDF/A&lt;/a&gt; action can confirm the PDF/A-3 container itself conforms to ISO 19005, a useful gate before anything ships, though it checks the container, not ZUGFeRD's own business-rule validation of the embedded XML. Build that check into your pipeline before this touches production traffic, not after the first rejected invoice comes back.&lt;/p&gt;

&lt;p&gt;Which conformance level to pick isn't really a technical question, it's a contractual one. Ask the receiving business, or their invoicing software vendor, which profile they expect. EN16931 and XRECHNUNG are the two most commonly required for the German mandate specifically, so match what's asked for rather than defaulting to EXTENDED because it sounds more thorough.&lt;/p&gt;

&lt;h2&gt;
  
  
  Getting started
&lt;/h2&gt;

&lt;p&gt;Every action above sits behind the same &lt;a href="https://docs.pdf4me.com/general-guidelines/connect-to-pdf4meapi/" rel="noopener noreferrer"&gt;PDF4me V2 REST API&lt;/a&gt;, so the authentication and base URL story is identical whether you're calling Create PDF/A and Add Attachment to PDF yourself, or letting Create ZUGFeRD Invoice do both in one step through whichever automation platform your team already runs on.&lt;/p&gt;

&lt;p&gt;Germany's mandate is the concrete deadline in front of anyone shipping invoices there today. Building the pipeline once, with a tool that already understands PDF/A-3's attachment mechanics, beats re-explaining to a new hire every few months why an invoice PDF has a file attached to it.&lt;/p&gt;

&lt;p&gt;Website: &lt;a href="https://pdf4me.com/" rel="noopener noreferrer"&gt;pdf4me.com&lt;/a&gt;&lt;br&gt;
Documentation: &lt;a href="https://docs.pdf4me.com/" rel="noopener noreferrer"&gt;docs.pdf4me.com&lt;/a&gt;&lt;br&gt;
Developer portal: &lt;a href="https://dev.pdf4me.com/" rel="noopener noreferrer"&gt;dev.pdf4me.com&lt;/a&gt;&lt;/p&gt;

</description>
      <category>einvoicing</category>
      <category>api</category>
      <category>compliance</category>
      <category>automation</category>
    </item>
  </channel>
</rss>
